Docs

Markdown rendering

Compose Markdown from typed components, as a first-class interface for humans and agents.

Open Markdown

For an agent, context is the scarce resource, and most of a web page is scaffolding. A real HTML document averages over 80K tokens, of which more than 90% are CSS, JavaScript, comments, and other tokens carrying no content. Converting that page to Markdown drops about 90% of its tokens, roughly 10× fewer. Generating the Markdown from structured content instead of scraping it back out of the DOM does better still: Sanity measured one lesson page at 392 KB of HTML, about 100K tokens, against 13 KB of Markdown, about 3,300.

That ratio decides how much of your site an agent holds before it has to compact, and every compaction is an extra model call that drops detail it will not get back.

Proa's goal is to bring the latency of the web as close to zero as possible, for everyone. An agent that spawns a browser to read a page, or compacts because the page was mostly markup, is paying latency you can delete.

Markdown interfaces have been an afterthought until now: scraped, converted from HTML, or hand-maintained beside the real templates. Proa makes rendering a trait rather than a fixed output format, so one component implements WebRender for HTML and MdRender for Markdown. One typed tree, two outputs, neither derived from the other.

src/components/product_summary.rs
use proa_core::{DataLoader, MdRenderSync, WebContext, WriteError};
use proa_macros::md_sync;

pub struct ProductSummary {
    pub title: &'static str,
    pub description: &'static str,
}

impl<L: DataLoader> MdRenderSync<L> for ProductSummary {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            ## {self.title}

            {self.description}
            "###
        }
        .render_md(cx)
    }
}

That makes Markdown composable. Components nest, loop, and take children, so a SearchPage builds a document out of fifty Listing components and none of them concatenates a string. Change Listing and every document containing one changes.

Writing a template

Reach for this when a route serves docs source, an agent-readable representation, a feed, or any dense text output that is not HTML.

Follow Editor setup for go-to-definition, hover, completion, and rename inside r###"..."### templates in VS Code, Neovim, and other LSP editors.

Good to know: cargo proa fmt re-anchors a template to its macro's indentation and cargo proa lint reports one that has drifted, but both tools only see brace-delimited invocations. Write md_sync! { ... }, not md_sync!( ... ).

Choosing a trait and a macro

TraitTemplate macroUse it when
MdRenderSync<L, B>md_sync!The component writes Markdown without .await.
MdRender<L, B>md!The component may await, or composes async Markdown children.

HTML and Markdown share WebContext<L, B>. Both Markdown traits default to L = NoLoader and B = RenderBuffer, in that order. Application components usually implement MdRenderSync<L> or MdRender<L> and take &mut WebContext<L>; the owned buffer needs no lifetime parameter. Keep B as the second generic only when a component needs to support custom writers.

Create an owned root with WebContext::new(), render with component.render_md(&mut cx), and consume cx.into_output() afterward. WebContext::with_buffer(&mut out) also accepts a borrowed writer for components implemented over that writer type.

Use md_sync! by default:

src/pages/summary.rs
use proa_macros::md_sync;

let summary = md_sync! { r###"
    # {title}

    {description}
    "###
};

Use md! when an interpolated child awaits:

src/pages/report.rs
use proa_core::render_md_to_string_async;
use proa_macros::md;

let output = render_md_to_string_async(
    md! { r###"
        # Report

        {proa_core::await_md_component(load_report_body())}
        "###
    },
)
.await;

Strings and numbers work directly in both macros. Lift a custom sync-only child into an async tree with to_md_async(...), and use await_md_component(...) when a future resolves to a Markdown child.

The canonical form is one multiline raw Markdown template. Use three hashes by default: md_sync! { r###"..."### } or md! { r###"..."### }. Source newlines render directly, so ordinary paragraphs, sections, and lists do not need "\n" or "\n\n". Common Rust source indentation is stripped while deeper relative Markdown indentation is preserved.

Each {expression} is a typed render child. Template for and if blocks compose the same way, while braces inside fenced code blocks and inline code spans remain literal Markdown. Outside code, write {{ or }} for a literal brace. Three hashes let ordinary quotes and Rust raw strings with fewer hashes appear without escaping; increase the delimiter only when the document itself contains the exact closing sequence "###.

The expression-visible token form, md_sync! { "# " { title } }, remains available for generated templates and tooling that needs interpolations represented as Rust tokens.

An async child cannot appear inside md_sync!. Move the boundary to MdRender and md!.

Loops and branches

Control flow is written inside the template, not as Rust blocks between nodes. {for ...} and {if ...} take a Markdown body and repeat or gate it:

src/pages/summary.rs
let summary = md_sync! { r###"
    # {title}

    ## Features

    {for feature in features.iter() {
    - {feature}
    }}
    {if in_stock {In stock} else {Backordered}}
    "###
};

The body of a loop or branch is Markdown, not a quoted string, so - {feature} is a literal list item with one interpolated value. Both macros share this grammar; in md! the interpolated children may await.

Good to know: Interpolation, slots, and children follow the same rules as the HTML macros. See Components for the composition model; the difference here is that a Markdown template is one string literal, so control flow lives inside it.

Composing documents from components

A slot holds a component, not a string. Give the child an MdRenderSync impl:

src/components/listing.rs
impl<L: DataLoader> MdRenderSync<L> for &Listing {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            ### {self.title}

            **${self.price}/night** · ★{self.rating}
            "###
        }
        .render_md(cx)
    }
}

Then interpolate it. The {listing} slot dispatches into that impl, the same way an html_sync! slot dispatches into a WebRenderSync child:

src/pages/search.rs
impl<L: DataLoader> MdRenderSync<L> for &SearchPage {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            # {self.title}

            {self.listings.len()} stays

            ---

            {for listing in self.listings.iter() {
            {listing}
            }}
            "###
        }
        .render_md(cx)
    }
}
output
# Malibu stays

2 stays

---

### Cliffside cottage

**$420/night** · ★4\.9

### Surf shack

**$180/night** · ★4\.7

The page never learns that a listing renders as an h3, and never allocates a String for one.

Note the 4\.9: rating is a string here, and string interpolations are Markdown-escaped, so data cannot inject structure. A title of # Free money arrives as text, not a heading. Numbers are written verbatim, so an f64 rating would print 4.9.

One component, two representations

The same struct implements both traits. The HTML impl emits chrome; the Markdown impl emits meaning:

src/components/callout.rs
impl<L: DataLoader> WebRenderSync<L> for &Callout {
    fn render(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        html_sync! {
            <aside data-callout role="note">
                <strong>{self.label}</strong>
                <p>{self.body}</p>
            </aside>
        }
        .render(cx)
    }
}

impl<L: DataLoader> MdRenderSync<L> for &Callout {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        md_sync! { r###"
            > **{self.label}:** {self.body}
            "###
        }
        .render_md(cx)
    }
}

One value, rendered two ways:

Accept: text/html
<aside data-callout role="note"><strong>Note</strong><p>Cross-origin writes return 403.</p></aside>
Accept: text/markdown
> **Note:** Cross\-origin writes return 403\.

A component with no text form renders its content instead: a button becomes its label, a tab strip becomes its panels. The component decides, because it is the only thing that knows. See Content negotiation for serving both from one URL.

Block boundaries: the contract that makes nesting safe

Markdown is whitespace-sensitive, which is what usually makes it uncomposable. A heading is only a heading at the start of a line, and a component cannot know what its previous sibling wrote.

So a component that emits block-level Markdown calls md_block_boundary() before its first block byte and after its last:

src/components/section.rs
impl<L: DataLoader> MdRenderSync<L> for Heading {
    fn render_md(self, cx: &mut WebContext<L>) -> Result<(), WriteError> {
        cx.md_block_boundary()?;
        md_sync! { r###"## {self.0}"### }.render_md(cx)?;
        cx.md_block_boundary()
    }
}

It writes only the newlines that are missing: two of these back to back produce one blank line, not four, and the first one in a document writes nothing.

output
Intro line.

## First

## Second

That is what lets any block component sit next to any other, in any order.

Inline context is the other half. A newline would break a table cell or a link label, so a parent marks the span inline and boundaries become no-ops:

src/components/button/md.rs
let prev = cx.md_set_inline(true);
self.children.render_md(cx)?;
cx.md_set_inline(prev);

The same Heading that emits \n\n## Inline\n\n at block level emits ## Inline there. Components degrade instead of corrupting the document. md_set_inline returns the previous value, so a nested span restores what it found rather than assuming it started at block level.

Embedded HTML

Markdown permits inline HTML, but keep it on the typed HTML path instead of mixing an HTML parser into the Markdown macro:

src/components/callout.rs
use proa_macros::html_sync;

cx.render_html_in_md(html_sync! {
    <aside data-callout>
        <strong>{self.title}</strong>
    </aside>
})?;

WebContext::render_html_in_md(...) reuses the current loader, output, and request metadata while tracking Markdown boundaries. Its child receives an observed writer; custom HTML components used here must support generic B: WriteBuf. html_sync! applies the normal compile-time HTML validation and escaping rules.

Good to know: Slots, children, and branching behave identically to the HTML macros. See Components; everything there applies with md_sync! in place of html_sync!.

Data loading

Markdown uses the same DataLoader API as HTML. An MdRender<L> implementation can await cx.load_typed(...), cx.load_bytes(...), or cx.load_json(...). Start the root with WebContext::with_loader(Arc::clone(&loader)); nested Markdown children and tracked HTML bridges keep that same loader instance. MdRenderSync<L> can participate in the same tree, but cannot await a load. See Data loading for request-scoped loaders.

For an async Axum route, return the plan from negotiate_async_with_loader(...) or explicit_markdown_async_with_loader(...). The adapter creates the local render future during finalization, so the handler can remain Send without requiring a Send Markdown render future.

Streaming Markdown

Async Markdown roots can use the in-order stream driver from proa_stream:

src/pages/report.rs
use std::rc::Rc;

use proa_core::StreamChunkFlusher;
use proa_stream::render_md_in_order_to_flusher;

let flusher: Rc<dyn StreamChunkFlusher> = Rc::new(MyMarkdownFlusher::new());

render_md_in_order_to_flusher(PageMd, flusher).await?;

The driver renders through the shared WebContext into the standard RenderBuffer, flushes through StreamChunkFlusher, and preserves Markdown-local state across Suspense boundaries. Pass an existing request loader with render_md_in_order_to_flusher_with_loader(PageMd, loader, flusher) when the page loads data.

Use it for text/markdown responses where a generated document should stream top to bottom instead of buffering the whole body first.

Next steps

Search

Type at least 2 characters