# Markline > Open-source, self-hostable documentation framework with first-class OpenAPI, an interactive API playground, instant search, and AI on your own key (BYOK). --- # Quickstart Create and run a documentation site in under a minute. ## Install & scaffold ```bash npm create @markline@latest my-docs ``` This creates a `markline.json`, sample MDX pages, an example OpenAPI spec, and ready-to-use deploy configs. ```bash cd my-docs npm install npm run dev ``` Already have a project? `npm install @markline/markline` then `npx markline init`. ## Project structure Your repository holds only content and configuration: ```text my-docs/ ├── markline.json # navigation, theme, branding, API settings ├── docs/ # your pages (*.mdx) │ ├── index.mdx │ └── quickstart.mdx └── api/ └── openapi.json # rendered as an interactive API reference ``` The OpenAPI file is optional — Markline works as a docs-only site too. ## Develop Edit files under `docs/` and update `markline.json` to change navigation, branding, and the API base URL. When you're ready, see [Deployment](/deployment). --- # CLI The markline command runs your docs project from its own markline.json and content. ## Commands | Command | Description | | --- | --- | | `markline init [dir]` | Scaffold a new docs project. | | `markline dev` | Start the dev server against `./markline.json`. | | `markline build` | Build a production Node server bundle. | | `markline start` | Serve a production build. | | `markline export` | Build a static HTML site into `./out`. | ## How it works The Markline package ships the rendering app; the CLI launches it pointed at the content in your current directory. You never fork or edit the framework — you upgrade it like any dependency. ```bash npx markline dev ``` Live preview on http://localhost:3000. ```bash npx markline export ``` Outputs `./out` for any static host. ```bash npx markline build && npx markline start ``` A Node server with full features. --- # Configuration markline.json is the single source of truth for navigation, theming, and behavior. ## Theme & branding The site name, used in the header and metadata. `{ light, dark }` image paths, or `{ text }` for a wordmark. `{ primary, primaryDark }` brand accents (hex). See [Theming](/theming) for the full palette and fonts. Default color scheme. ## Topbar Right-aligned header links, e.g. a GitHub link. `{ label, href }` for the primary "Get started" button. A small label pill next to the logo (e.g. `docs · v1`). Topbar layout on docs + the API reference. `"full"` (default) spans edge-to-edge; `"contained"` centers it to the content max-width. The homepage topbar, independent of `width`. Defaults to `"contained"` so the marketing nav aligns with the hero. ## Navigation Navigation is a list of tabs, each with grouped pages. A tab can instead set `openapi: true` to generate its sidebar from your OpenAPI spec. ```jsonc "navigation": { "tabs": [ { "id": "docs", "label": "Documentation", "href": "/", "match": ["__default__"], "groups": [{ "group": "Start", "pages": [{ "href": "/", "label": "Intro" }] }] }, { "id": "api", "label": "API", "href": "/api-reference", "match": ["/api-reference"], "openapi": true } ] } ``` ## API & playground Base URL the playground sends requests to (overrides the spec's servers). The "Try it" experience. Default `"full"`. How the playground reaches your API. `never` is required for static export. See [API reference](/openapi) for the full guide — generating the reference, the live explorer, and per-resource/per-operation MDX overlays. ## Integrations Base URL for "Edit this page" links; the page path is appended. `plausible`, `googleAnalytics`, or `posthog`. Force the "Was this helpful?" widgets on or off. Omitted → shown only when an `endpoint` is set. See [Feedback](/feedback). POST target for the "Was this helpful?" widgets (same JSON from docs pages and API resource sections). Point it at a webhook, a Sheet, your own API, or the bundled [`feedback-worker`](/feedback#worker). See [Feedback](/feedback) for the payload and deploy steps. ## Versions & i18n Documentation versions; the first is the default (unprefixed). Localizations; the first locale is the default. Same mechanism as versions. A project uses versions **or** locales — both share the URL-prefix mechanism. See [Versions & i18n](/versions) for the directory layout and worked examples. --- # Theming Brand your docs entirely from markline.json — logo, favicon, fonts, colors, and the full palette. No code to fork. Everything visual is driven by the `theme` block (and a couple of `topbar` fields) in your config — no component overrides required. ## The config file Markline reads **`markline.json`**, falling back to **`docs.json`** so projects migrating from a `docs.json` config work unchanged. All theming lives under `theme` in that file. Image paths (`logo`, `favicon`) are served from your project's `public/` directory. ## Default theme The default color scheme. `"system"` follows the OS (the default); set `"dark"` or `"light"` to force one. Visitors can still toggle, and their choice is remembered. ```jsonc "theme": { "appearance": "dark" } // open in dark mode by default ``` ## Logo & favicon The logo can be an **icon + wordmark** (the common docs pattern), light/dark **images**, or just **text**. Small mark shown next to the name (e.g. `/logo-mark.svg`). Wordmark text shown beside the icon (defaults to `name`). Full logo images, swapped by color scheme. Used when no `icon` is set. Favicon path, e.g. `/favicon.svg`. ```jsonc "theme": { "logo": { "icon": "/logo-mark.svg", "text": "acme" }, "favicon": "/favicon.svg" } ``` ## Accent color Brand accent (hex) for light mode. Brand accent for dark mode (and hover). ```jsonc "theme": { "colors": { "primary": "#4f46e5", "primaryDark": "#818cf8" } } ``` Using a **light** accent (e.g. lime or yellow)? White text on it is invisible. Set the on-accent text color with the `--c-on-brand` variable (see below) — it controls text/icons on brand-colored surfaces (buttons, badges, numbered cards). ## Full palette For deeper theming, override any CSS custom property via `theme.cssVariables`, split into `light` and `dark`. **Color values are space-separated RGB triples** (e.g. `247 246 241`), not hex. Always provide both `light` and `dark` if you customize the palette — otherwise dark mode falls back to Markline's default dark. The tokens you'll reach for most: | Token | Meaning | | --- | --- | | `--c-brand`, `--c-brand-deep` | Accent + hover (also set via `colors`) | | `--c-on-brand` | Text/icon color on brand surfaces | | `--c-paper`, `--c-paper-2` | Page and raised surface backgrounds | | `--c-ink`, `--c-ink-soft`, `--c-ink-2`, `--c-ink-3` | Text shades | | `--c-slate-0` … `--c-slate-8` | Neutral scale (borders, muted text) | ```jsonc "theme": { "cssVariables": { "light": { "--c-paper": "247 246 241", "--c-ink": "15 15 14", "--c-on-brand": "15 15 14" }, "dark": { "--c-paper": "18 18 14", "--c-ink": "232 231 224", "--c-on-brand": "15 15 14" } } } ``` ## Fonts Font-family overrides. The font must be system-available or self-loaded (e.g. via a `@font-face` in a stylesheet you include). Defaults to Geist. ```jsonc "theme": { "font": { "sans": "Inter", "mono": "JetBrains Mono" } } ``` ## Label pill A small bordered pill shown next to the logo — handy for a version or status label, e.g. `"docs · v1"` or `"beta"`. ## Worked example A complete warm, lime-accented theme that defaults to dark: ```jsonc { "name": "acme", "theme": { "appearance": "dark", "logo": { "icon": "/mark.svg", "text": "acme" }, "favicon": "/mark.svg", "colors": { "primary": "#D7FC50", "primaryDark": "#C2E63D" }, "cssVariables": { "light": { "--c-on-brand": "15 15 14", "--c-paper": "247 246 241", "--c-ink": "15 15 14" }, "dark": { "--c-on-brand": "15 15 14", "--c-paper": "18 18 14", "--c-ink": "232 231 224" } } }, "topbar": { "badge": "docs · v1" } } ``` Changing `theme` values takes effect on the next `markline dev` reload — no rebuild of the framework, ever. --- # Components Rich components available in any MDX page — no import required. ## Callouts A neutral note. A helpful suggestion. Something to watch out for. A destructive or irreversible action. A confirmation of success. ## Cards Link cards group neatly. Use them for navigation. ## Steps Describe the first action. Describe the next one. ## Tabs & accordions First tab content. Second tab content. Yes — any MDX works inside these components. Yes, including the interactive ones. ## Param fields The unique identifier. Maximum number of results. --- # API reference Point Markline at an OpenAPI document and it generates a polished three-pane reference — resource pages, multi-language code, and a live request explorer. Markline turns an OpenAPI 3.0/3.1 document into a full reference: a left resource nav, per-resource pages with attribute and parameter tables, dark code rails (cURL / Node / Python / Go), and an inline **request explorer**. ## Add a spec Put it at `api/openapi.json` in your content root. A remote URL works too — snapshot it into that file at build time so your docs never depend on a live service. ``` content/ ├── markline.json └── api/ └── openapi.json ``` Give a navigation tab `openapi: true` — its sidebar is generated from the spec's tags and operations. ```jsonc "navigation": { "tabs": [ { "id": "docs", "label": "Documentation", "href": "/", "match": ["__default__"], "groups": [] }, { "id": "api", "label": "API reference", "href": "/api-reference", "match": ["/api-reference"], "openapi": true } ] } ``` Point the explorer at your API. `api.baseUrl` overrides the spec's `servers`. ```jsonc "api": { "baseUrl": "https://api.example.com", "playground": { "mode": "full", "proxy": "auto" } } ``` ## What you get The reference is **per-resource**: one page per tag, opening on the first resource. Each page has: - A **resource summary** + an Endpoints card listing the tag's operations. - A **"The object" section** — attributes derived from the schema, with types, required/optional flags, enums, and a sample JSON. - One **section per endpoint** — method + path, parameters (path / query / body) from the schema, and a dark code rail with **cURL, Node, Python and Go** samples generated from the operation, plus the example response. Resource pages live at `/api-reference/`; the per-operation pages remain reachable too (deep links, search). Navigation between docs and the reference is instant — it's one app, not a separate site. ## Nested tags Slash-separated tags — the convention NestJS emits from `@ApiTags('store/orders')` — become a **nested sidebar**. No config: Markline groups by shared prefix and title-cases each segment (with an acronym map, so `fx` → `FX`, `api` → `API`). ``` store/products Store store/orders → ├── Products store/refunds ├── Orders admin/users └── Refunds admin/api/keys Admin ├── Users └── API └── Keys ``` - **Parent groups are nav-only** accordions (expand/collapse), opening to the active resource. Each **leaf** tag stays a normal resource page. - **URLs are unchanged** — the slug is still the whole tag, so `store/orders` lives at `/api-reference/store-orders` and existing links keep working. - Depth is arbitrary (`admin/api/keys` nests three levels); the page title shows the leaf (`Keys`) with an `Admin / API` breadcrumb. - Single-segment tags (`payments`) are unchanged — plain top-level entries. This is automatic for any spec using slash tags — you don't flatten your `@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 samples — cURL, Node, Python, Go — from the operation itself: the Node/Python/Go ones are SDK-style (`client..(…)`), inferred from the tag, `operationId`, and the body / path / query parameters. You shape the rail two ways. ### Pick the generated languages `api.codeSamples` chooses **which generated languages appear, and in what order**. It's a subset of `"curl" | "node" | "python" | "go"`; omit it and all four show. ```jsonc "api": { "baseUrl": "https://api.example.com", "codeSamples": ["curl", "node"] // just cURL + Node, in that order } ``` If you don't ship an SDK, the generated Node/Python/Go snippets reference a client that doesn't exist. Set `"codeSamples": ["curl"]` to show only cURL — or author your own per-operation samples with `x-codeSamples` below. ### How generated samples are named There's no separate "SDK name" setting — the generated snippets build their identifiers from your spec. A sample like `acme.payments.create({ … })` is assembled from four derived tokens: | Token | Derived from | Example | | --- | --- | --- | | **client** | first word of `info.title`, lowercased, alphanumerics only (else `client`) | `"Acme Payments API"` → `acme` | | **resource** | the operation's **tag**, lowercased and stripped | tag `Payment Methods` → `paymentmethods` | | **method** | HTTP verb + path shape | `POST`→`create`, `PUT`/`PATCH`→`update`, `DELETE`→`del`, `GET` with `{id}`→`retrieve`, `GET` without→`list` | | **result** | singularized tag | `projects` → `project` | So the two levers on the generated rails are **`info.title`** (the client name) and **your tag names** (the resource accessors). To match a real SDK exactly — custom client name, hand-written method signatures, a language Markline doesn't generate — reach for `x-codeSamples`. ### Bring your own with `x-codeSamples` To replace the generated rail on a specific operation, add the `x-codeSamples` extension to that operation **in your OpenAPI document** (not in `markline.json`). The older `x-code-samples` spelling is accepted too — Markline reads either, so specs written for Redoc/Stripe work unchanged. ```jsonc "paths": { "/v1/payments": { "post": { "operationId": "createPayment", "summary": "Create a payment", "x-codeSamples": [ { "lang": "ruby", "label": "Ruby", "source": "Acme::Payment.create(\n amount: 1000,\n currency: \"usd\",\n)" }, { "lang": "php", "source": "$client->payments->create([\n 'amount' => 1000,\n 'currency' => 'usd',\n]);" } ] } } } ``` Each entry has three fields: The code shown in the tab. Rendered **verbatim** — HTML-escaped, with no re-highlighting — so format it exactly as you want it to read, newlines (`\n`) and indentation included. The tab label. Falls back to `lang`, then to `"Sample"`. Free-form language hint (`ruby`, `php`, `java`, `kotlin`, …) — **not** limited to the four generated languages. Used for the tab's internal key and as the label fallback. `x-codeSamples` is **all-or-nothing per operation**: as soon as an operation carries one custom sample, the entire generated rail for that operation is replaced, and `api.codeSamples` no longer applies to it. List *every* language you want shown — tabs appear in array order. Other operations keep their generated rails untouched. This is the escape hatch for hand-written, copy-paste-correct examples: real SDK calls, framework-specific snippets, or languages Markline can't generate. ## The playground Write endpoints get an inline **request explorer**: an editable `Authorization` field, the request fields, a **Send** button, and the live response with status + latency. It's the same engine on every surface. `full` — inline inputs + the request console + the API Explorer modal. `inline` — inline inputs + the request console, no modal. `explorer` — read-only docs + console + the API Explorer modal. `off` — a static cURL panel, no interactivity. How requests reach your API. `auto` (default) tries a direct browser fetch and falls back to the built-in server proxy on CORS failure; `always` forces the proxy; `never` is direct-only — **required for static export**. The server proxy (`/api/playground`) only runs on a Node/Vercel deployment and is dropped in static export. On a static host, set `proxy: "never"` and enable CORS on your API, or the explorer can't reach it. ## Events & webhooks If your API emits webhooks or async events, Markline documents them alongside the endpoints that trigger them. Each resource grows an **Events** tab listing the events it can deliver — name, summary, payload schema, and a sample — and the two are cross-linked: an emitting endpoint shows a **Triggers** chip, and the event shows an **Emitted by** back-link to that endpoint. The tab only appears on resources that actually have events; a spec with none looks exactly as before. ### Where events come from Markline reads events from four sources and normalizes them into one model, so you can use whichever your toolchain already emits: | Source | Where it lives | Use it when | | --- | --- | --- | | `webhooks` | document root (OpenAPI **3.1**) | your spec is 3.1 and uses the native webhooks object | | `x-webhooks` | document root (Redoc extension) | you're on 3.0 and want a Redoc-compatible root list | | `callbacks` | on an operation | the event is a standard OpenAPI callback tied to one request | | `x-events` | on a **tag** or an **operation** (Markline) | you want the lightest annotation, especially from codegen like NestJS | For root `webhooks` / `x-webhooks`, each entry is a Path Item — Markline takes its first method operation and reads `summary`, `description`, the operation's `tags`, and the JSON request body as the payload. **A root webhook only appears on a resource if its operation carries a matching `tags` entry**; untagged webhooks are parsed but have no resource to attach to. ### The `x-events` extension `x-events` is the most direct way to declare events. It takes either a **map** of `name → definition`, or an **array of names** (references to events defined elsewhere — no inline payload): ```jsonc "x-events": { "project.created": { "summary": "A new project was created.", "description": "Emitted once the project is provisioned and ready.", "payload": { "$ref": "#/components/schemas/Project" }, "guide": "/guides/webhooks#project-created" } } ``` ```jsonc "x-events": ["project.created", "project.deleted"] // name-only references ``` Each definition field is optional: One-line description, shown as the event's heading. Longer prose shown under the summary. The delivered payload. Inline schema or a `$ref` into `components.schemas` — resolved at render to produce the attribute table and the sample JSON. (`schema` and a JSON `requestBody` are accepted as fallbacks.) Link to a docs page or anchor that explains the event in depth, rendered as a follow-on link. (`guideHref` / `href` are accepted too.) **Tag level vs. operation level** changes how the event is wired: - On an **operation** (`paths...x-events`), the event inherits that operation's `tags`, so it groups onto those resources — and because an endpoint triggers it, the endpoint gets a **Triggers** chip and the event an **Emitted by** back-link. - On a **tag** (`tags[].x-events`), the event attaches to that resource only, with no emitter back-link. Use this for events not tied to a single endpoint — delivered by an external processor, a batch job, or a state change. Markline **merges** the two per resource and de-duplicates by event name, so an event declared on both a tag and an operation appears once. The sample spec at `api/openapi.json` in this repo is wired up: `createProject` emits `project.created`, `createDeployment` emits `deployment.succeeded` / `deployment.failed`, and a root `x-webhooks` entry adds `project.archived` to the Projects resource. Open the [Projects reference](/api-reference/projects) to see the Events tab. ### From NestJS NestJS has no `@ApiCallbacks`, but `@ApiExtension` is a clean fit — and it's why `x-events` exists. Annotate the handler that causes the event; Markline aggregates it onto the resource automatically (no document-root wiring): ```ts @ApiTags('cards') @ApiExtraModels(CardCreatedEvent) // emits the payload into components.schemas @Controller('cards') @Post() @ApiExtension('x-events', { 'card.created': { summary: 'A new card was issued', payload: { $ref: getSchemaPath(CardCreatedEvent) }, guide: '/guides/webhooks#card-created', }, }) create() { /* … */ } } ``` - `@ApiExtraModels(CardCreatedEvent)` makes `getSchemaPath(...)` resolve — without 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` 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: ```ts applyDecorators(ApiExtension('x-events', events)); // @Emits({ 'card.created': { summary: 'A new card was issued', payload: { $ref: getSchemaPath(CardCreatedEvent) } } }) ``` For an event with **no** single triggering endpoint (one a processor 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 (callouts, tables, tabs) included: ``` api/ ├── openapi.json ├── introduction.mdx # replaces the whole reference landing ├── sections/.mdx # rich intro for a resource (overrides the tag description) └── operations/.mdx # extra prose on one operation page ``` - **`sections/.mdx`** renders as the resource intro. The filename is the slugified tag — `Payment Methods` → `payment-methods.mdx`. - **`operations/.mdx`** renders between the endpoint path and the auto-generated parameters. ## AI & page actions Every resource section carries the same affordances as the docs: **Copy for LLM** (copies the section as Markdown), **View as Markdown**, and — when [Ask AI](/ai) is enabled — **Ask about this section**, which opens the docked assistant with that section as context. ## Versions The reference header shows a version selector. With a single spec it surfaces the spec's `info.version` (a semver `1.4.2` → `v1 · 1.4.2`; a date `2025-06-01` → shown once, verbatim). 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-per-version) for the folder layout. --- # Search Full-text search across your docs and API reference — built into the static output, no service required. Markline ships a command-palette search (⌘K) backed by a **static index** built at compile time. There's nothing to run and no third-party search service. ## How it works At build, Markline indexes your content — Markdown/MDX pages **and** the OpenAPI reference — into a static index emitted alongside the site. At runtime the palette loads that index in the browser and queries it client-side, so search works on any static host. The index is generated automatically by `markline build` / `markline export`. It covers page titles, headings, body text, and API operations. ## Using it - Press ⌘K (or /) anywhere, or click **Search docs** in the header / **Find anything** in the API reference. - Results are grouped and keyboard-navigable (↑/↓ to move, ↵ to go). - When [Ask AI](/ai) is enabled, the palette also offers an **Ask Assistant** row that hands your query to the docked assistant. ## Hosting Search is part of the static build, so it works on any CDN, GitHub Pages, S3, or a Node server. For **sub-path hosting**, set `MARKLINE_BASE_PATH=/prefix` so the search bundle is loaded from the right path (see [Deployment](/deployment)). --- # Deployment Ship your docs as static HTML or a Node server — markline init scaffolds the configs. ## Static hosting ```bash markline export # → ./out ``` Upload `out/` to any static host — a CDN, GitHub Pages, Netlify, or S3. The scaffolded `.github/workflows/deploy.yml` exports and deploys on push to `main`, building under `/` for project sites. `netlify.toml` sets the build command to `npx markline export` and publishes `out`. On static hosts the API playground runs in direct-fetch mode. Set `api.playground.proxy` to `"never"`. ## Node server ```bash markline build && markline start ``` This enables the playground's server-side proxy for APIs without CORS. The scaffolded `Dockerfile` builds and serves the site: ```bash docker build -t my-docs . && docker run -p 3000:3000 my-docs ``` ## Sub-path hosting Serving under a path prefix (e.g. a GitHub Pages project site)? Set the base path so assets and search resolve correctly: ```bash MARKLINE_BASE_PATH=/my-repo markline export ``` --- # Versions & i18n Ship multiple documentation versions or languages from one project — same URL-prefix mechanism, a switcher in the header, fully static. Markline serves multiple **versions** (v2, v1, …) or **locales** (en, es, …) from a single project. Both use the same idea: the **first** entry is the default and lives at the root; every other entry is served under a **URL prefix** with its own content folder — and the header shows a switcher. ## How it works There's one rule for the whole folder layout: **the default version lives at the content root; every other version lives in a folder named after its `id`.** A version folder is just a smaller mirror of the root — same `docs/` and `api/openapi.json` inside. ``` content/ ├── markline.json # config + the versions list ├── docs/ # DEFAULT version docs → /quickstart │ └── quickstart.mdx ├── api/ │ └── openapi.json # DEFAULT version spec → /api-reference └── v1/ # one folder per non-default version id ├── docs/ # v1 docs → /v1/quickstart │ └── quickstart.mdx └── api/ └── openapi.json # v1 spec → /api-reference/v1 ``` So a version `id` is **both** a folder name and a URL prefix. The one wrinkle to remember — docs and the API reference prefix the `id` in *different spots*: | Content file | Default URL | Version `v1` URL | | --- | --- | --- | | `docs/quickstart.mdx` | `/quickstart` | `/v1/quickstart` | | `api/openapi.json` | `/api-reference` | `/api-reference/v1` | (Docs put the version first; the API reference keeps everything under `/api-reference` and puts the version after it — so all API routes stay in one place.) A project uses **versions or locales**, not both — they share the prefix mechanism (if both are set, versions win). The default entry is always unprefixed, so existing URLs don't change when you add a second version. A version folder only needs the parts that differ — drop `api/` and it's docs-only; drop `docs/` and it's reference-only. ## Versions List versions in `markline.json`. The first is the default; each entry is `{ id, label }` and may carry its own `navigation`. ```jsonc "versions": [ { "id": "v2", "label": "v2 (latest)" }, // default — content in docs/, served at / { "id": "v1", "label": "v1" } // content in v1/docs/, served at /v1/… ] ``` Create `v1/docs/` and author its pages — they're independent from the default, so v1 can lag or differ freely. A version entry can set `navigation: { tabs: [...] }` to override the sidebar for that version; otherwise it inherits the default navigation. `markline build` emits every version statically. The header shows a **version switcher**; picking one navigates under its prefix. ## API reference per version Each version can ship its **own OpenAPI spec**. Drop it at `/api/openapi.json` and Markline serves that version's reference at `/api-reference/` — generated exactly like the default, with its own resource pages, code samples, and interactive explorer. ``` content/ ├── api/openapi.json # default → /api-reference └── v1/ └── api/openapi.json # v1 → /api-reference/v1 ``` - The **version selector** in the API-reference header lists every version and switches the spec in place (the default is marked `Default`). - Within a version, every link — resource pages, search results, the explorer's endpoint switcher — stays under that version's `/api-reference/` prefix, so you never fall back to the default spec mid-navigation. - Per-resource and per-operation **MDX overlays** are version-scoped too: `/api/sections/.mdx` and `/api/operations/.mdx`. - The pill label follows the spec's `info.version` — a semver like `1.4.2` shows as `v1 · 1.4.2`; a date like `2025-06-01` shows once, verbatim. A version that has no `api/openapi.json` simply has no API reference (its selector entry won't resolve a spec) — versions can be docs-only, reference-only, or both. ## Localization Locales work identically — `i18n.locales`, first is the default (unprefixed), each other locale under its `id` prefix with content in `/docs/`. ```jsonc "i18n": { "locales": [ { "id": "en", "label": "English" }, // default — docs/, served at / { "id": "es", "label": "Español" } // es/docs/, served at /es/… ] } ``` ``` content/ ├── docs/ # English (default) → /quickstart └── es/ └── docs/ # Spanish → /es/quickstart ``` The header shows a **language switcher** that jumps to the same page under the chosen locale prefix. ## Notes - The **default** entry (first in the list) is never prefixed — keep your canonical content in `docs/`. - Each variant is fully static and independently navigable; search and the API reference work within each. - Configure these in [Configuration](/configuration); for hosting the built output see [Deployment](/deployment). --- # AI for your documentation (BYOK) An opt-in AI assistant for your docs and API reference — running on your own LLM key, never ours. Markline ships an optional **Ask AI** assistant: a docked chat panel on the API reference with "Ask about this section" and source citations. It is **bring your own key (BYOK)** — Markline never provides a model or a key, and the feature is strictly opt-in. Omit the `ai` block (or set `enabled: false`) and there is no AI route, no client weight, and no UI. The framework is byte-for-byte unchanged. ## How it works Two principles drive the design: 1. **The key is a server-side secret.** In `proxy` mode it is read only from `MARKLINE_AI_KEY` in the environment — never from `markline.json` (which is committed and public), never from a `NEXT_PUBLIC_*` variable, never in the browser bundle. 2. **One OpenAI-compatible transport.** A single code path speaks the `/chat/completions` shape, so OpenAI, OpenRouter, Together, Groq, Fireworks and local models (Ollama / LM Studio) all work by changing one `provider` preset or `baseUrl`. ## Configuration Add an `ai` block to `markline.json`, and provide the key via the environment. ```jsonc // markline.json — committed, public. NO key here. "ai": { "enabled": true, "provider": "openrouter", // preset, or "openai-compatible" + baseUrl "model": "deepseek/deepseek-v4-flash", "mode": "proxy", // "proxy" (operator key) | "byok" (reader key) "endpoint": null, // external proxy URL (static hosting) — see below "label": "Ask AI", "maxTokens": 1024, "rateLimit": { "perMinute": 10 } } ``` ```bash # .env.local / host env — the secret. Never NEXT_PUBLIC_*. MARKLINE_AI_KEY=sk-or-... ``` Restart `markline dev` after editing `markline.json` — config is read once per process. ## Providers Pick a `provider` preset, or use `openai-compatible` with an explicit `baseUrl`. | `provider` | resolved base URL | |---|---| | `openai` | `https://api.openai.com/v1` | | `openrouter` | `https://openrouter.ai/api/v1` | | `together` | `https://api.together.xyz/v1` | | `groq` | `https://api.groq.com/openai/v1` | | `fireworks` | `https://api.fireworks.ai/inference/v1` | | `local` | `http://localhost:11434/v1` (Ollama / LM Studio) | | `openai-compatible` | **requires `baseUrl`** | The `model` is the provider's model id — e.g. `deepseek/deepseek-v4-flash` or `openai/gpt-4o-mini` on OpenRouter. ## Hosting modes Where your docs run decides which mode fits: | Host | Mode | Key location | |---|---|---| | Node / Docker / Vercel (`markline start`) | `proxy` | `MARKLINE_AI_KEY` on the server → built-in `/api/ai` route | | Pure static (GitHub Pages / S3 / CDN) | `proxy` + `endpoint` | key on an external Worker you deploy (below) | | Any host, including static | `byok` | the **reader's** key, in their browser | `proxy` mode needs a server. The built-in `/api/ai` route is a dynamic route and is **dropped in static export** — so on a pure-static host with `mode: "proxy"` and no `endpoint`, `Ask AI` renders nothing. Use an `endpoint` (below) or `mode: "byok"`. In **`byok`** mode the assistant asks each reader for their own key (stored in their browser) and calls the provider directly. It costs the operator nothing and works on static hosting, at the price of a little reader friction. ## Static + Cloudflare Worker To run Ask AI on **your** key from a **static** site, deploy the open-source reference Worker in [`templates/ai-worker/`](https://github.com/markline-dev/markline/tree/main/templates/ai-worker). It is the same proxy logic as the built-in route, plus the CORS handling a cross-origin endpoint needs. From `templates/ai-worker/`: ```bash npm install npx wrangler secret put MARKLINE_AI_KEY # paste your provider key npm run deploy # → https://markline-ai..workers.dev ``` The key is stored as a Cloudflare secret on the edge — never in git, never in the browser. Edit `wrangler.toml` `[vars]` — `MARKLINE_AI_PROVIDER`, `MARKLINE_AI_MODEL`, and `MARKLINE_ALLOWED_ORIGIN` (pin it to your docs domain; this is also the abuse allow-list). Re-run `npm run deploy`. In `markline.json`, set `mode: "proxy"` and `endpoint` to the Worker URL: ```jsonc "ai": { "enabled": true, "mode": "proxy", "provider": "openrouter", "model": "deepseek/deepseek-v4-flash", "endpoint": "https://markline-ai..workers.dev" } ``` With `endpoint` set, the Ask AI UI appears even in a static export. Rebuild and redeploy your docs. ## Cost & abuse A public AI proxy is an open relay to your paid LLM — enabling it spends your budget. Both the built-in route and the Worker ship with conservative defaults: - **Origin allow-list** — only your docs domain(s) may call it. - **Per-IP rate limit** — `ai.rateLimit.perMinute` (route) / `MARKLINE_RATE_PER_MIN` (Worker). - **Token cap** — `ai.maxTokens` / `MARKLINE_AI_MAX_TOKENS`, plus an input-length clamp. Tune the rate limit and pick a cheap model for public sites. The Worker's rate limit is per-isolate in-memory; for strict global limits, back it with Workers KV or a Durable Object. --- # Feedback "Was this helpful?" widgets on every docs page and API resource — opt-in, sink-agnostic, with a drop-in Cloudflare Worker for static hosting. Markline ships optional **"Was this helpful?"** widgets on two surfaces: - **Docs pages** — a per-**page** rate block in the TOC rail: 👍/👎 → a reason → an optional comment → submit. - **API reference** — a compact per-**resource** "Yes / No" that submits immediately, tagging *which* resource it was on. Both POST the **same JSON** to one endpoint, so any sink works. Answers are remembered per page/section in `localStorage`, and a failed request never blocks the reader — feedback is strictly best-effort. Feedback is **hidden by default** — the widgets render only when you configure them (below). Omit the `feedback` block and there is no widget and no network at all. ## How it works The framework is **sink-agnostic**: it only knows how to POST a small JSON blob to `feedback.endpoint`. Where that lands — a webhook, a spreadsheet, a database, the reference Worker — is yours to choose. Markline never stores feedback itself. ## Configuration ```jsonc // markline.json "feedback": { "enabled": true, // optional — see the table "endpoint": "https://feedback.example.com" // POST target } ``` POST target for both widgets. Unset → submissions are logged to the browser console (handy in dev). Force the widgets on or off. Omitted → shown **only when an `endpoint` is set** (so a widget never points nowhere). Visibility resolves like this: | `enabled` | `endpoint` | Widgets | |---|---|---| | _omitted_ | set | **shown** | | _omitted_ | unset | hidden | | `true` | any | **shown** (console-logs without an endpoint) | | `false` | any | hidden | Restart `markline dev` after editing `markline.json` — config is read once per process. ## What it sends A `POST` with `Content-Type: application/json`. The per-section payload is the base; the per-page widget adds `reason` and `comment`. ```jsonc // API reference — per section, on Yes/No { "answer": "yes", "scope": "section", "target": "payments", "path": "/api-reference/payments", "ts": 1733424000000 } ``` ```jsonc // Docs — per page, on Submit { "answer": "no", "scope": "page", "target": null, "path": "/guides/webhooks", "ts": 1733424000000, "reason": "Update this documentation", "comment": "the retry section is stale" } ``` | Field | Type | Notes | |---|---|---| | `answer` | `"yes"` \| `"no"` | the thumb / Yes-No | | `scope` | `"page"` \| `"section"` | which widget | | `target` | `string` \| `null` | resource slug for sections; `null` for pages | | `path` | `string` | the page it was left on | | `ts` | `number` | epoch ms | | `reason` | `string` | **page only** — one of the radio options | | `comment` | `string` | **page only** — free text | ## Sinks `feedback.endpoint` is just a URL that accepts that POST. Common choices: - A **webhook** that fans out to Slack / Discord. - A **Google Sheet** via an Apps Script web-app URL. - **Airtable**, a serverless function, or your own API. - The reference **Cloudflare Worker → D1** below — batteries-included for static hosting. ## Cloudflare Worker + D1 For a static site (GitHub Pages / S3 / any CDN) with no backend of your own, deploy the open-source reference Worker in [`templates/feedback-worker/`](https://github.com/markline-dev/markline/tree/main/templates/feedback-worker). It validates the payload, rate-limits, stores a row in **Cloudflare D1**, and can forward a line to **Slack**. From `templates/feedback-worker/`: ```bash npm install cp wrangler.toml.example wrangler.toml # your real config is gitignored npx wrangler d1 create markline-feedback # paste the database_id into wrangler.toml npx wrangler d1 execute markline-feedback --remote --file schema.sql ``` The `database_id` is an identifier, not a secret — but `wrangler.toml` is gitignored so your filled-in config never lands in the repo. In `wrangler.toml` `[vars]`, set `MARKLINE_ALLOWED_ORIGIN` to your docs domain(s) — this is the CORS / abuse allow-list. To forward to Slack: ```bash npx wrangler secret put MARKLINE_SLACK_WEBHOOK ``` ```bash npx wrangler deploy # → https://markline-feedback..workers.dev ``` Optionally add a [Custom Domain](https://developers.cloudflare.com/workers/configuration/routing/custom-domains/) (e.g. `feedback.your-docs.dev`) to hide the `workers.dev` URL, then set it in `markline.json`: ```jsonc "feedback": { "endpoint": "https://feedback.your-docs.dev" } ``` ```bash npx wrangler d1 execute markline-feedback --remote \ --command "SELECT created_at, answer, scope, target, path, reason, comment FROM feedback ORDER BY id DESC LIMIT 50" ``` ## Abuse & privacy - **Origin allow-list** (`MARKLINE_ALLOWED_ORIGIN`) keeps other sites from posting via a reader's browser — but it is **not** a security boundary (a script can forge `Origin`). Your real control is the **rate limit**. - **Per-IP rate limit** — `MARKLINE_RATE_PER_MIN` (default 30/min). It's per-isolate in-memory; for a hard cap, back it with KV / a Durable Object or a Cloudflare WAF rule. - **No raw IPs** are stored — only a short hash, for spotting dupes/abuse. - `comment` is **free text** and may contain PII; treat the table accordingly. A public feedback endpoint is unauthenticated by design. Keep the allow-list pinned and the rate limit conservative on high-traffic sites. --- # Open-source Mintlify alternative A modern docs experience without the SaaS — MIT-licensed, self-hostable, and yours to own. # The open-source Mintlify alternative Mintlify is a polished hosted docs platform — but it's a paid SaaS: your content and build pipeline run on someone else's infrastructure, with per-seat pricing and the usual platform lock-in. **Markline gives you the same modern docs experience as an MIT-licensed framework you fully own and self-host.** ## Why teams switch - **Own your stack.** Markline builds to plain static HTML you host anywhere — GitHub Pages, S3/CloudFront, Cloudflare, or your own CDN. No vendor runtime. - **No per-seat pricing.** It's MIT-licensed and free; add editors without a bill. - **Bring your own AI key.** The Ask-AI assistant runs on *your* LLM key (BYOK), not a metered hosted add-on. - **First-class OpenAPI.** A generated API reference with an interactive playground, three-pane layout, and per-language code samples. - **No lock-in.** Your content is MDX in your repo; the engine is open source. ## Markline vs Mintlify | | Markline | Mintlify | | --- | --- | --- | | License | Open source (MIT) | Proprietary | | Hosting | Self-host anywhere (static export) | Hosted SaaS | | Pricing | Free | Paid, per-seat | | OpenAPI reference | ✓ | ✓ | | Interactive playground | ✓ | ✓ | | Search | Built-in, static (Pagefind) | ✓ | | AI assistant | BYOK — your key | Hosted add-on | | Content format | MDX in your repo | MDX on the platform | | Lock-in | None | Platform | Feature sets and pricing change — check the current details on each project's site. Markline's column reflects what ships today. ## Migrate in minutes Markline reads MDX guide pages and an `openapi.json`, so moving over is mostly copying content. Point it at your docs and spec, set your theme, and ship: Scaffold a project, drop in your MDX and OpenAPI spec, and deploy a static site in a few minutes. --- # Open-source Scalar alternative More than an API reference — a full open-source docs framework that includes one. # The open-source Scalar alternative Scalar is a great open-source API reference. If you need **more than an API reference** — full guide and tutorial pages, search across everything, and an AI assistant — Markline is an open-source docs *framework* that does both, under one site and one nav. ## Where Markline goes further - **Docs *and* API in one site.** MDX guides and a generated OpenAPI reference live under the same navigation, with instant client-side routing between them. - **Instant static search** across your guides *and* the API reference — no external service. - **BYOK AI assistant** over your content, on your own LLM key. - **Interactive playground** on the reference, with multi-language code samples. - **One static build** you host anywhere. ## Markline vs Scalar | | Markline | Scalar | | --- | --- | --- | | License | Open source (MIT) | Open source | | Scope | Full docs framework (guides + API) | API reference / client | | MDX guide pages | ✓ | Limited | | OpenAPI reference | ✓ | ✓ | | Interactive playground | ✓ | ✓ | | Built-in search (guides + API) | ✓ | — | | BYOK AI assistant | ✓ | — | | Self-host (static) | ✓ | ✓ | Both are open source — choose Scalar for a focused API reference, Markline for a full docs site that includes one. Feature sets change; check each project's site for current details. ## Get started Point Markline at your MDX docs and `openapi.json` and ship a static docs + API site in minutes. --- # Framework guides Point Markline at the OpenAPI document your backend already emits — then use four small extensions to shape the reference without touching your routes. 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` 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). --- # NestJS Emit your OpenAPI document from @nestjs/swagger, snapshot it into your docs, and shape the reference with decorators that survive regeneration. 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 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') ``` 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 @ApiTags('accounts') @Controller('accounts') @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 // @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 @ApiTags('accounts') @ApiExtraModels(AccountCreatedEvent) @Controller('accounts') @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. --- # Hono + Zod OpenAPI Define routes and schemas once with @hono/zod-openapi, write the document to disk, and drop Markline's extensions straight into createRoute. `@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 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 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. --- # FastAPI FastAPI already emits OpenAPI 3.1 with native webhooks. Dump it to a file at build time and shape the reference with openapi_extra. 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 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. --- # Spring Boot 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. 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.