Docs

Markdown rendering

Compose Markdown as one of a component's render targets, sharing data and typed children across formats.

Open Markdown

Markdown is an interface for documentation, feeds, command-line clients, and content inside conversations. These consumers need a useful representation of the same application data a browser displays.

Proa makes rendering a trait rather than a fixed output format. A Rust component can share fields and typed children across HTML, Markdown, and other render targets, defining its output for each target. HTML uses WebRenderSync and Markdown uses MdRenderSync; other targets can define their own rendering traits. Use WebRender and MdRender when the HTML or Markdown implementation needs to await work.

For an agent consuming content, direct Markdown rendering also avoids extracting text from browser markup. The Markdown implementation chooses which content and structure to include, using the same component data as the HTML implementation.

src/components/product_summary.rs
use proa::{DataLoader, MdRenderSync, WebContext, WriteError};
use proa::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::md_sync;

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

    {description}
    "###
};

Use md! when an interpolated child awaits:

src/pages/report.rs
use proa::render_md_to_string_async;
use proa::md;

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

        {proa::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. Slots contain ordinary Rust control flow; branches re-enter Markdown through nested templates. 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 body is always one string literal. Token composition such as md_sync! { "# " { title } } is a compile error that points back to the string form. To render children in sequence without authoring Markdown, use a tuple: (overview, impact).render_md(cx).

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

The Markdown macros use the default-enabled markdown feature of proa. An HTML/XML-only application can disable default features to omit the Markdown parser dependencies. If defaults are disabled, opt in with features = ["markdown"] when using md! or md_sync!. Cargo enables a feature if any dependency requests it.

Loops and branches

The template has one lexical rule at every depth: text outside { ... } is Markdown, and text inside is a Rust expression. Control flow is ordinary Rust, exactly as in html_sync!, and a branch renders only its final expression. Earlier statements retain ordinary Rust side effects; a semicolon discards a value. Compose multiple fragments in one nested template. Markdown re-enters a branch through a nested md_sync! call:

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

    ## Features

    {for feature in features.iter() {
        md_sync! { "- {feature}\n" }
    }}

    {if in_stock {
        md_sync! { "**In stock**" }
    } else {
        md_sync! { "Backordered" }
    }}
    "###
};

A bare string tail such as "Backordered" renders as escaped text; markup needs the nested template. Use ordinary one-line string templates for short fragments, such as md_sync! { "- {feature}\n" }. Keep the row-ending \n so iterations do not run together. Reserve raw literals for substantial multiline sections or when they substantially reduce escaping, and use fewer hashes for nested raw literals:

src/pages/report.rs
md_sync! { r###"
    {for entry in self.entries {
        md_sync! { r#"
            ### {entry.question}

            {entry.answer}

            "#
        }
    }}
    "###
}

Because plain Markdown is never parsed inside a slot, if, for, and match are ordinary words in prose, and a line means the same thing at the top level and inside a branch. Writing Markdown directly inside a branch is a compile error that points at the nested-template form. Both macros share this grammar; in md! the nested templates and 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, multiple representations

Each render target has its own implementation on the same struct. Here, the HTML implementation emits a browser callout and the Markdown implementation emits a blockquote. The same component data can support other formats or rendering backends through additional implementations:

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.

A physically multiline raw template establishes block boundaries automatically:

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

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.

An ordinary-string template selects inline mode and adds no automatic paragraph boundaries. Explicit newlines, such as the one in md_sync! { "- {item}\n" }, remain output. Use an inline template for a label:

src/components/button/md.rs
md_sync! { "{self.children}" }.render_md(cx)?;

The mode is selected when the template renders, not when it is constructed. Nested scopes restore their enclosing mode on success, error, and panic unwinding. Async md! also restores it when a pending render is dropped. No closure or manual md_set_inline restoration is needed. Partial bytes and boundary tracking remain live after failure; error cleanup adds no trailing boundary.

Block templates inside an inline scope are errors, including typed children in link labels and table cells. A directly rendered qualified block macro is rejected at compile time when detectable; other block children fail at render time before writing their boundary or body. Earlier prefix bytes remain. Supply an inline representation instead of silently flattening a block.

Raw-string hashes only control delimiting. All physically multiline raw literals select block mode, including r"..."; single-line raw literals select inline mode. Ordinary strings stay inline even when they contain newlines. Existing dedenting and authored whitespace are unchanged: only automatic boundaries are deduplicated, and an empty block can still establish a boundary after preceding prose. The line breaks directly after a slot at block position are boundaries too: written after inline text, skipped after a child that already ended its line or paragraph, so a block child on its own line keeps exactly one blank line after it.

Inline/block scopes restore rendering mode only. Do not restore a full MdState snapshot to clean up formatting: it would replace suffix tracking without undoing emitted bytes.

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::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::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