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.
Point Docuccino at a folder
Section titled “Point Docuccino at a folder”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.
Frontmatter reference
Section titled “Frontmatter reference”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 requestssummary: 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.
Linking to your API
Section titled “Linking to your API”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 aMETHOD /pathsignature —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 undercomponents.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.
Content diagnostics
Section titled “Content diagnostics”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. |
Symbol-anchored prose
Section titled “Symbol-anchored prose”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.
What each format keeps
Section titled “What each format keeps”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.