Skip to content

Adding your own pages

A generated reference tells a reader what each endpoint does. It rarely tells them where to start, how to authenticate, or how the pieces fit together. Docuccino lets you write that narrative as plain Markdown, compile it into the same document as your reference, and link it directly to the operations and schemas it describes — with a guarantee those links can’t silently rot.

Set content.dir in docuccino.yaml to a folder of Markdown files, relative to your application root:

documents:
default:
content:
dir: 'resources/docs/api'

Every *.md file under that folder becomes a page. The folder structure becomes your navigation: each subfolder is a nav group by default, and the file’s location gives it a slug.

  • Directoryresources/docs/api/
    • overview.md
    • Directoryguides/
      • authentication.md
      • pagination.md
    • Directorywebhooks/
      • signing.md

That tree compiles to two groups — Guides and Webhooks — plus a top-level Overview page, all without any configuration. Frontmatter (next section) overrides any of it when the defaults aren’t what you want.

A relative content.dir is confined to your application root, and a directory that doesn’t exist raises a content.dir-missing warning rather than failing the build — so a typo tells you instead of silently producing no pages.

A leading --- block sets a page’s metadata. Every key is optional — omit the block entirely and Docuccino derives sensible defaults from the file’s name and location.

Key Type Default Behavior
title string File name, humanized (getting-started.md → Getting Started) The page’s display title.
slug string Path under content.dir, without .md The page’s stable slug; its identity is derived from this, so keep it stable across renames. Slugs must be unique — a collision is an error and the later page is dropped.
summary string none A short description carried onto the page.
tags string[] none Freeform tags carried onto the page.
nav.group string Parent folder name, humanized (no group at the root) The sidebar group the page sits under.
nav.order integer unordered Explicit sort weight within its group.
nav.hidden boolean false Compile the page but leave it out of the navigation tree.
nav.type page | operation | tag page Whether the nav entry links to this page, or references an operation or a tag.
nav.ref string none For type: operation or tag, the target — an operationId or METHOD /path, or a tag name.

A typical page:

---
title: Authenticating requests
summary: How to obtain and send a bearer token.
nav:
group: Guides
order: 1
---
Every request to a protected endpoint carries a bearer token…

Ordering. Within a group, pages sort by nav.order, then title, then slug; pages with no order come after those that have one. A group takes the lowest order among its children, so giving one page in a folder order: 1 lifts the whole group.

Setting nav.type: operation with a nav.ref turns a nav entry into a link straight to an operation in your reference — handy for surfacing a key endpoint in the sidebar. A nav.type: tag entry points at a tag name (either one your operations carry, or one you defined in tags.definitions). A ref that doesn’t resolve raises content.unresolved-nav-ref and the entry is left out, rather than shipping a dead link.

A group links to a destination once. Two pages in one group naming the same operation or tag keep the one the group’s order puts first — nav.order, then title, then slug — and the other raises content.duplicate-nav-ref. Pointing at one endpoint from two different groups is fine, and stays.

Inside a page, reference an operation or schema with a directive. Docuccino resolves it against the assembled document at build time:

Paginated results come back from ::operation{id="invoices.index"}, each item shaped like
::schema{name="InvoiceData"}.
  • ::operation{id="…"} takes an operationId, or a METHOD /path signature — GET /api/v1/invoices, matching the OpenAPI path template, with the method case-insensitive. The signature form is how you address a route that has no name.
  • ::schema{name="…"} takes a component schema name, as it appears under components.schemas.

When the build runs, each directive is checked against the real document. A directive that resolves gets the operation or schema’s stable identity appended as a ref, so a consumer can link to it without re-resolving:

::operation{id="invoices.index" ref="op:v1:mfz3q6k2w5r7t4ua"}

A directive that points at something that doesn’t exist becomes an error diagnostic — the directive is left as you wrote it, and your build can fail on it with --fail-on=error. This is the whole point: your guides can’t quietly drift away from the API they describe. Rename an endpoint and forget to update the guide, and the next build tells you.

An unknown directive name — anything other than ::operation or ::schema — passes through untouched with a warning, so directives your own renderer understands are safe to use.

Every code below is in the diagnostics reference too, alongside what to do about each one.

Code Severity Means
content.dir-missing warning The configured content.dir doesn’t exist.
content.dir-escapes-base warning The configured content.dir doesn’t name a path inside your application; it was ignored and no pages were compiled.
content.duplicate-slug error Two pages resolved to the same slug; the later one was dropped.
content.frontmatter-not-a-switch warning A frontmatter switch — nav.hidden — holds something that is neither true nor false; it was refused and the default used. Frontmatter is YAML, where no, off, yes and on are strings.
content.unresolved-directive error An ::operation/::schema directive resolved to nothing, or is missing its selector attribute.
content.unknown-directive warning A directive Docuccino doesn’t handle; passed through untouched.
content.unresolved-nav-ref error A nav.type: operation/tag entry pointed at something absent.
content.duplicate-nav-ref warning Two pages in one nav group point at the same operation or tag; the second entry was left out.
content.duplicate-operation-id warning Two operations share an operationId, so ::operation{id="…"} is ambiguous.

There’s a second way to bring Markdown into your docs, for a different job. When the prose belongs to one specific symbol — a controller action, or a Data class — attach it at the source with #[Description]:

use Docuccino\Attributes\Description;
#[Description(file: 'resources/docs/invoices/index.md')]
public function index(): AnonymousResourceCollection { /* … */ }

The file’s contents become that operation’s description, right where the endpoint appears in the reference. The path is resolved within your application root, and the file joins the operation’s cache dependencies — so editing the Markdown correctly invalidates just that fragment.

Prose for consumers, docblocks for maintainers

Section titled “Prose for consumers, docblocks for maintainers”

By default an action’s docblock is its prose: the first paragraph becomes the summary, the rest becomes the description. That works right up until the docblock has something to say to whoever maintains the code — dispatched by the worker, don’t call this directly — which is not something an API consumer can use, or should read.

Two @ tags settle it without touching the prose above them:

/**
* Internal — dispatched by the queue worker, never call this directly.
*
* The retry budget is three attempts.
*
* @summary Void an invoice
*
* @description Marks an invoice void. Voiding is permanent and cannot be undone.
*/
public function void(Invoice $invoice): JsonResponse { /* … */ }

Declaring either tag hands the whole consumer-facing text over to the tags: the free prose stops feeding both fields, so the note above them never reaches the document, and a field you didn’t state comes out empty rather than half-quoting a note meant for someone else.

#[Summary] and #[Description] say the same two things one rung higher, so they win over both the tags and the prose. Reach for the tags when you’d rather not import anything, and for the attributes when the prose lives in a file or you want the override to be obvious at a glance.

The content layer is the clearest example of what the Docuccino extension is for — plain OpenAPI has nowhere to put a page tree. What survives an export depends on the format:

Format Guide pages & nav tree Descriptions in standard fields
The full document (--format=full) Kept in full under x-docuccino.content Kept
OpenAPI 3.2 / 3.1 / 3.0 Dropped — no home in the OpenAPI object Kept (they’re standard OAS fields)

In other words: your standalone guide pages and the navigation tree live in the extension. Exporting to OpenAPI drops them, because there’s no standard place for them to go. Descriptions that sit in ordinary OpenAPI fields — info.description, tag descriptions, and per-operation descriptions (including ones set with #[Description]) — survive, because they were standard fields all along.

Rendering the guide pages and their multi-page navigation is a job for a consumer of the full document. The bundled viewer serves OpenAPI, so it shows the reference — rich descriptions and all — but not the standalone guides; the same is true of any plain OpenAPI tool you point at the export. Today the content layer is for pipelines you build on the full document: a docs site generator, a changelog, or an agent tool catalog that wants your prose alongside your operations.