Docs

Rendering pages

Put a docs site inside your Proa app.

Open Markdown

Your build emits a static registry. This page mounts it: include the registry, add a route, serve the static artifacts, and render a page.

Including the registry

The build crate handles Markdown parsing, frontmatter resolution, table-of-contents extraction, pager links, search JSON, and generated Rust emission. At runtime your app includes the static Docs registry and picks one of two paths:

src/main.rs
static DOCS: proa_docs::Docs = proa_docs::include_docs!("docs");

The argument must match DocsSource::out_dir_name(...) from build.rs:

build.rs
// build.rs
let docs = proa_docs_build::DocsSource::new("src/docs")
    .out_dir_name("docs")
    .base_path("/docs")
    .site_name("Proa Docs");

include_docs!("docs") expands to an include! path under $OUT_DIR/proa_docs/docs/docs.generated.rs. That generated file holds only static data plus include_str! references to generated HTML, Markdown, and search JSON.

Everything your app touches at runtime comes from the handful of types below.

Reading the registry types

TypePurpose
DocsRoot registry: config, pages, nav, and optional search artifacts.
DocPageOne compiled page: route path, title, HTML, Markdown, TOC, prev, next.
NavItemNested sidebar tree item with title, optional icon, optional path, and children.
TocItemHeading entry with depth, ID, and title.
DocsResponseRuntime response wrapper for HTML, Markdown, JSON, or 404.
DocsNavDefault nav renderer.
DocsTocDefault table-of-contents renderer.
DocsPagerPrevious/next page renderer.

The core data model is inspectable on purpose:

tests/docs_registry.rs
assert_eq!(DOCS.config.base_path, "/docs");
assert_eq!(DOCS.config.site_name, "Proa Docs");

for page in DOCS.pages {
    println!("{} -> {}", page.slug, page.route_path);
}

Routing a request means turning a path into one of those DocPage values.

Finding pages

src/routes/docs.rs
let page = DOCS.page_for_path("/docs/guides/installation")
    .expect("known docs page");

assert_eq!(page.slug, "guides/installation");

find_page("guides/installation") looks up by slug. page_for_path("/docs/guides/installation") strips the configured base path first. It also normalizes trailing slashes, a trailing index.html, and .md suffixes.

tests/docs_registry.rs
assert_eq!(
    DOCS.page_for_path("/docs/guides/installation/")
        .map(|page| page.slug),
    Some("guides/installation"),
);

assert_eq!(
    DOCS.find_page("guides/installation.md")
        .map(|page| page.route_path),
    Some("/docs/guides/installation"),
);

You can call first_page() for an intentional fallback. Do not use it as a silent 404 replacement in production, unless you want unknown docs URLs to render the first page.

Some requests never reach a page at all, and render_static answers those.

Serving static artifacts

render_static handles generated non-page assets:

src/routes/docs.rs
if let Some(response) = proa_docs::render_static(&DOCS, path) {
    return docs_response_to_axum(response);
}

It returns:

Those responses carry the correct content types:

MethodContent type
DocsResponse::htmltext/html; charset=utf-8
DocsResponse::markdowntext/markdown; charset=utf-8
DocsResponse::jsonapplication/json; charset=utf-8
DocsResponse::not_foundtext/plain; charset=utf-8

The default renderer wires that check together with page lookup for you.

Rendering with the default shell

proa_docs::render(&DOCS, path) returns a DocsResponse using the default DocsShell renderer:

src/routes/docs.rs
pub async fn docs_handler(Path(path): Path<String>) -> impl IntoResponse {
    let path = format!("/docs/{path}");
    let response = proa_docs::render(&DOCS, &path);

    (
        StatusCode::from_u16(response.status).unwrap(),
        [(CONTENT_TYPE, response.content_type)],
        response.body.into_owned(),
    )
}

render checks render_static first. If the path is not a static artifact, it renders the matching page. If no page matches, it falls back to first_page(), and it returns 404 only when the registry has no pages.

Production sites outgrow that shell, so proa_docs exposes the pieces separately.

Building a custom shell

Production sites want their own shell, header, section picker, search modal, and 404 behavior. Look up the page yourself and render the pieces you want:

src/routes/docs.rs
use axum::{http::StatusCode, response::{IntoResponse, Response}};

pub fn docs_response(path: &str) -> Response {
    if let Some(response) = proa_docs::render_static(&DOCS, path) {
        return docs_response_to_axum(response);
    }

    let Some(page) = DOCS.page_for_path(path) else {
        return StatusCode::NOT_FOUND.into_response();
    };

    html_response(DocsPageView { docs: &DOCS, page })
}

Inside the page view, render the generated article HTML with UnsafeRaw. The compiler already produced trusted HTML from local Markdown files:

src/routes/docs_page.rs
use proa_core::{UnsafeRaw, WebContext, WebRenderSync, WriteError};
use proa_docs::{DocPage, Docs, DocsNav, DocsPager, DocsSectionMenu, DocsToc};
use proa_macros::html_sync;

struct DocsPageView {
    docs: &'static Docs,
    page: &'static DocPage,
}

impl<L: proa_core::DataLoader> WebRenderSync<L> for DocsPageView {
    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        html_sync! {
            <main class="docs-layout">
                <aside>
                    {DocsSectionMenu {
                        docs: self.docs,
                        active_path: self.page.route_path,
                    }}
                    {DocsNav {
                        items: self.docs.nav_items_for_path(self.page.route_path),
                        active_path: self.page.route_path,
                    }}
                </aside>
                <article>{UnsafeRaw(self.page.article_html)}</article>
                <aside>{DocsToc { items: self.page.toc }}</aside>
                <footer>{DocsPager { page: self.page }}</footer>
            </main>
        }
        .render(cx)
    }
}

Keep UnsafeRaw scoped to page.article_html. Never use it for request data, CMS data, or user-generated Markdown.

DocsSectionMenu and Docs::nav_items_for_path live in proa_docs, so custom shells share one section picker and one scoped-sidebar behavior. Sections come from folders whose meta.json sets "root": true, so your app supplies no section list of its own.

A custom shell often fronts more than one registry.

Including multiple registries

Apps can include more than one generated registry:

src/main.rs
static DOCS: proa_docs::Docs = proa_docs::include_docs!("docs");
static BLOG: proa_docs::Docs = proa_docs::include_docs!("blog");

Route each registry by its configured base path:

src/routes/docs.rs
if path.starts_with("/blog") {
    return proa_docs::render(&BLOG, path);
}

proa_docs::render(&DOCS, path)

Each of those DocsResponse values still needs a conversion into your framework's response type.

Using the Axum adapter

With the axum feature enabled, proa_docs::adapter::axum::response(&DOCS, path) renders the path and returns an Axum Response with the status, content type, and body of the underlying DocsResponse.

Use the adapter for a default docs site. Write your own adapter when the app needs custom status handling, metrics, headers, or route-level layout.

Run through the list below before you ship either one.

Runtime checklist

Next steps

Search

Type at least 2 characters