# Proa documentation corpus This plain text endpoint concatenates the Markdown source used to build the Proa site. --- # Docs: Quickstart URL: /docs Description: Create a Proa site, run it, and edit your first route in five minutes. --- title: Quickstart description: Create a Proa site, run it, and edit your first route in five minutes. sidebar_title: Quickstart --- This quickstart gets you from an empty directory to a hello world Proa. It should take about five minutes once Rust, GitHub access to the Proa repository, and the Proa CLI are ready. Building with an AI agent? The complete documentation is served at [/llms-full.txt](/llms-full.txt) for agent consumption. [LLM docs](/docs/llms) lists the other machine-readable interfaces. ## 1. Install Proa Install the CLI with the one-line installer. ```bash curl -fsSL https://proa.so/install.sh | sh ``` ```powershell irm https://proa.so/install.ps1 | iex ``` The current prebuilt binary targets 64-bit Ubuntu 24.04+. On macOS, Windows, ARM Linux, and older Linux releases, the installer builds the CLI from source automatically; while the source repository is private, that fallback requires GitHub access to it. ```bash CARGO_NET_GIT_FETCH_WITH_CLI=true \ cargo install --git https://github.com/proa-labs/proa --locked proa-cli ``` The prefix matters: Proa crates are git dependencies, and the git CLI fetch picks up credential helpers and proxy settings that Cargo's built-in fetcher does not. [Troubleshooting](/docs/guides/troubleshooting) has the details. Set `PROA_CLI_VERSION` to a version that is already published on the stable channel, then pass it to the installer: ```bash curl -fsSL https://proa.so/install.sh | PROA_VERSION="$PROA_CLI_VERSION" sh ``` ## 2. Create A Site The kitchen-sink starter, and the fastest way to see a production Proa app fit together: ```bash proa new site origin-atlas --template origin --yes ``` This scaffolds ORIGIN, a live routing atlas that exercises the whole framework: a custom RIPEstat `DataLoader` with request-scoped deduplication and a TTL cache, streamed Suspense sections, proa-ui components that each carry a Markdown representation (the same page serves `/site/.md` for agents), rsjs islands for the interactive surfaces, Tailwind styling with a themed token set, self-hosted fonts, and a golden test that pins the server-rendered document byte for byte. The generated `README.md` doubles as an editing guide. Checkpoint: `src/lib.rs`, `src/loader.rs`, and `src/components/` should exist, and `proa dev --open` shows the atlas. A minimal Axum-backed application with the core boundaries in place: ```bash proa new site hello-proa --yes ``` ```proa-filetree Cargo.toml proa.config.json proa.lock.json build.rs src/ components/ endpoints/ layouts/ pages/ app.rs main.rs routes.rs migrations/ public/ favicon.svg styles.css styles/ input.css tests/ ``` Checkpoint: `Cargo.toml`, `src/main.rs`, `src/pages/home.rs`, and `src/layouts/root.rs` should exist. You do not have to build the common UI yourself: [proa-ui](https://ui.proa.so) is a shadcn-style catalog of ready-made Proa components, and `proa ui add button` vendors the source into `src/components/` where you own and edit it like any other file. Worth knowing before you write your first card, dialog, or form by hand. ## 3. Run The Server ```bash cd hello-proa && proa dev --open ``` `proa dev` builds configured assets, runs the linked RSJS pipeline, starts the Axum server on [http://localhost:3000](http://localhost:3000), and restarts when Rust, style, or config files change. Open tabs reload themselves once the rebuilt server is accepting connections again, so you do not have to refresh by hand. Stylesheet edits are applied without navigating, which keeps scroll position and form state. Turn it off or retune it with `--no-reload`, `--no-css-hot-swap`, and `--debounce-ms`; see [Live reload](/docs/cli/new-site#live-reload). ## 4. Edit The First Page Set up [your editor](/docs/guides/editor-setup) for Rust navigation and completion, including expressions inside raw Markdown templates. Open `src/pages/home.rs` and find the `HomePage` render implementation. The default site ships with a database, so the page is an async `WebRender` impl built with `html!`; sites scaffolded without one use `WebRenderSync` and `html_sync!`. Either way, replace the placeholder copy inside the `
` with content for your first route, keeping the macro the file already uses: ```rust html! {

"Proa"

"Fast SSR, typed in Rust"

"This page renders from a compiled template macro."

} ``` Refresh the browser. You have now edited a server-rendered Proa route and seen the compiled template update in a real page. ## 5. Add A Route Create `src/pages/about.rs`: ```rust use axum::response::IntoResponse; use proa_core::{to_async, DataLoader, WebContext, WebRenderSync, WriteError}; use proa_framework_axum::RouteResponse; use proa_macros::html_sync; use crate::layouts::RootDocument; pub async fn handler() -> impl IntoResponse { RouteResponse::ssr_async(RootDocument { title: "About", content: to_async(AboutPage { heading: "About", body: "This route renders from src/pages/about.rs.", }), }) } struct AboutPage { heading: &'static str, body: &'static str, } impl WebRenderSync for AboutPage { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { html_sync! {

{self.heading}

{self.body}

} .render(cx) } } ``` Export it from `src/pages/mod.rs`, keeping the generated `home` module: ```rust pub mod about; pub mod home; ``` Register it in `src/routes.rs`, between the markers the generator maintains: ```rust let (router, metadata) = router_from_spec(vec![ // proa:route-specs:start Route::endpoint("/", get(pages::home::handler)), Route::endpoint("/about", get(pages::about::handler)), // proa:route-specs:end ]); ``` That is the whole route flow. `RouteResponse` renders the page and finalizes the HTTP response, `RootDocument` supplies the shared page shell (it is async-capable, so a synchronous page goes in through `to_async`), and the fields passed to `AboutPage` are compile-time-checked props. Add [page caching](/docs/framework/page-caching) to the route spec when the response should carry CDN cache headers. Add Axum extractors such as `Path`, `Query`, or `State` to `handler` when the route needs request data. Open [http://localhost:3000/about](http://localhost:3000/about). Check the route table: ```bash proa site routes ``` Checkpoint: `/about` appears in the route report and renders in the browser. ## 6. Validate Before Moving On Run Proa's source-aware checks before the full build: ```bash proa fmt --check --verify src proa lint src --deny warnings proa build ``` `proa fmt --check --verify` checks the canonical template layout and verifies that formatting would preserve the rendered output. `proa lint` understands Proa render paths, so `--deny warnings` catches performance problems such as intermediate rendered strings, avoidable allocations, and async renderers that never await. Once those focused checks are clean, `proa build` validates the complete linked application. ## Next steps - [Tutorial](/docs/guides/tutorial) - Add typed data, components, routes, and a form action. - [proa new site](/docs/cli/new-site) - Scaffold a project and see where pages, layouts, components, and assets live. - [HTML rendering](/docs/core/html-rendering) - Render typed Rust values to HTML with render traits and the html! macros. - [Troubleshooting](/docs/guides/troubleshooting) - Fix the most common Proa setup, registry, template, component, island, routing, and CI failures. - [Deploy](/docs/framework/deployment) - Pick a deployment target, then follow the guide for it. --- # Docs: Editor setup URL: /docs/guides/editor-setup Description: Configure VS Code, Neovim, Vim, Helix, and Zed for Rust navigation and completion inside Proa Markdown templates. --- title: Editor setup description: Configure VS Code, Neovim, Vim, Helix, and Zed for Rust navigation and completion inside Proa Markdown templates. keywords: [editor, vscode, "VS Code", VSIX, rust-analyzer, proa-lsp, Neovim, nvim, Vim, vim-lsp, Helix, Zed, autocomplete, "go to definition"] --- Proa's editor tools add Rust go-to-definition, hover, completion, rename, and semantic highlighting inside raw-string `md!` and `md_sync!` templates, including `r###"..."###`. They use rust-analyzer for Rust analysis. ```rust md_sync! { r###" **{a.user_name.as_str()}** {a.note.as_str()} "### } ``` With editor support enabled, selecting `user_name` in this template navigates to its Rust field declaration. Hover shows its type, and completion works while typing an unfinished access such as `{a.}`. Use the **Proa extension** in VS Code. In other editors, configure **`proa-lsp`** as the Rust language-server command. For Tailwind class completion in HTML templates, see [Tailwind](/docs/guides/tailwind-intellisense). ## VS Code Install the official [rust-analyzer extension](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer) and the Rust toolchain used by your project. Open the project in a trusted workspace. The current Proa extension can be packaged from a [Proa repository checkout](https://github.com/Proa-Labs/proa). With Node.js 22 or newer installed, run these commands from the repository root: ```bash cd editors/vscode npm test npx @vscode/vsce package --no-dependencies code --install-extension ./proa-0.2.0.vsix ``` You can also install the resulting file through **Extensions: Install from VSIX...** in the Command Palette. Reload the VS Code window after installing. The extension ID is `proa-labs.proa`; `proa new site` recommends that ID in generated projects. These installation steps use a local VSIX and do not require a Proa Marketplace release. If you used the earlier `proa-labs.proa-markdown` prototype, uninstall it before enabling Proa so that only one extension handles template analysis. Proa attaches to the official rust-analyzer extension's existing server. No workspace settings are required, and `rust-analyzer.server.path` should keep pointing to your normal analyzer, not `proa-lsp`. ## Install the bridge for other editors The standalone bridge requires **Node.js 22+**, **rust-analyzer**, and your project's Rust toolchain, including Cargo and rustfmt. From the Proa repository root: ```bash rustup component add rust-analyzer rustfmt npm install --global ./editors/lsp proa-lsp --version ``` This installs from the checkout and does not require an npm registry release. Configure your editor to launch `proa-lsp --stdio`; the bridge starts one rust-analyzer process for that LSP connection. The default analyzer is `rust-analyzer` on `PATH`. To choose a particular executable, add these arguments to your editor's command: ```bash proa-lsp --stdio --rust-analyzer /absolute/path/to/rust-analyzer ``` `PROA_RUST_ANALYZER` can set the same path. Node, Cargo, and rustfmt must also be available to the editor process, including in remote sessions and containers. Use the bridge for your existing Rust LSP connection, or disable the direct rust-analyzer connection before adding a new one. Running both on the same buffer produces duplicate results. ## Neovim Neovim **0.11 or newer** includes the LSP configuration API used below. If nvim-lspconfig already provides your `rust_analyzer` configuration, change its command in `init.lua`. Your other settings and completion plugins can stay in that configuration: ```lua vim.lsp.config('rust_analyzer', { cmd = { 'proa-lsp', '--stdio' }, }) vim.lsp.enable('rust_analyzer') ``` For a setup without an existing Rust LSP configuration, use this instead: ```lua vim.lsp.config('proa', { cmd = { 'proa-lsp', '--stdio' }, filetypes = { 'rust' }, root_markers = { 'Cargo.toml', 'rust-project.json', '.git' }, settings = { ['rust-analyzer'] = {} }, }) vim.lsp.enable('proa') ``` Open a Rust file and run `:checkhealth vim.lsp`. There should be one attached Rust client. Use `K` for hover and the usual `vim.lsp.buf.definition()`, `vim.lsp.buf.rename()`, and `vim.lsp.buf.format()` functions through your preferred keymaps. See [Neovim's LSP documentation](https://neovim.io/doc/user/lsp/) for completion and keymap configuration. ## Vim Install [vim-lsp](https://github.com/prabirshrestha/vim-lsp) using your plugin manager, then register the bridge in your vimrc: ```vim augroup proa_lsp autocmd! autocmd User lsp_setup call lsp#register_server({ \ 'name': 'proa', \ 'cmd': {server_info -> ['proa-lsp', '--stdio']}, \ 'allowlist': ['rust'], \ 'workspace_config': {'rust-analyzer': {}}, \ }) augroup END ``` Disable any existing direct Rust server registration. Use vim-lsp's `:LspDefinition`, `:LspHover`, `:LspRename`, and `:LspDocumentFormat` commands. Completion and semantic coloring depend on your Vim client and plugins. ## Helix Override the command for Helix's existing Rust server in `~/.config/helix/languages.toml`: ```toml [language-server.rust-analyzer] command = "proa-lsp" args = ["--stdio"] ``` This retains your Rust language settings. See [Helix language-server configuration](https://docs.helix-editor.com/languages.html#language-server-configuration) for additional options. ## Zed Use `command -v proa-lsp` to find the bridge's absolute path, then set it as the command for Zed's existing Rust server in `settings.json`: ```json { "lsp": { "rust-analyzer": { "binary": { "path": "/absolute/path/to/proa-lsp", "arguments": ["--stdio"] } } } } ``` See [Zed's Rust binary settings](https://zed.dev/docs/languages/rust#binary). ## Verify the setup Open a Rust file in your Cargo project that uses a raw Markdown template. With the example at the top of this page: 1. Use go-to-definition on `user_name` and check that it reaches the field declaration. 2. Hover over the field to inspect its Rust type. 3. Replace `a.user_name.as_str()` with `a.` and request completion. The fields on `a` should appear. 4. Restore the expression, then run Format Document. The Markdown content should remain intact. Template loops and conditions, including nested blocks and `if let`, retain their Rust bindings. Unicode text before an expression does not move its navigation target. ## Formatting and supported scope Format Document runs rustfmt on the original unsaved Rust file, using the owning Cargo target's edition and nearest rustfmt configuration. It respects `rust-analyzer.rustfmt.extraArgs` and `rust-analyzer.rustfmt.overrideCommand`. Use [proa fmt](/docs/cli/format-lint-check) to additionally normalize indentation inside the Markdown template. Analysis does not rewrite files on disk. Edits that would overwrite literal Markdown are rejected. Range and on-type formatting are suppressed in files with projected templates. The current editor support covers single raw-string templates with closed delimiters. Cooked strings, named-argument templates, and unclosed template delimiters fall back to native Rust analysis. Braces inside Markdown code spans, fenced code, and escaped braces remain literal. References inside **unopened raw templates** remain limited to rust-analyzer's native analysis. Open those files before a workspace rename and review the resulting edits. Markdown prose keeps the editor's string styling; semantic Rust colors depend on the editor's support for LSP semantic tokens. VS Code and Neovim have real-editor integration tests covering navigation, completion, rename, formatting, unsaved edits, and restart. The Vim, Helix, and Zed configurations use their documented LSP interfaces; they have not yet received equivalent end-to-end tests in Proa. ## Troubleshooting | Symptom | Check | |---------|-------| | No Rust features inside raw templates in VS Code | Enable both Proa and the official rust-analyzer extension, trust the workspace, and reload the window. | | `proa-lsp` cannot start | Check `node --version`, `proa-lsp --version`, and `rust-analyzer --version` from the environment that launches your editor. | | A rustup shim reports that rust-analyzer is missing | Run `rustup component add rust-analyzer` for the project's toolchain, or select a working executable with `--rust-analyzer`. | | Duplicate diagnostics or completion entries | Keep one Rust LSP connection per buffer and disable the old Proa Markdown prototype if installed. | | UTF-16 initialization error | Include `utf-16` in the client's `general.positionEncodings` capability. | | Formatting fails | Check that Cargo and rustfmt are available and that any configured rustfmt override accepts the document on stdin. | The bridge logs to stderr; use your editor's LSP log to inspect failures. In Neovim, use `:checkhealth vim.lsp` and `:LspLog`. Restart the Rust LSP client after changing its command or toolchain path. --- # Docs: Tutorial: A Product Page URL: /docs/guides/tutorial Description: Server-render a catalog, then add a quantity stepper that still works with JavaScript off. --- title: "Tutorial: A Product Page" description: Server-render a catalog, then add a quantity stepper that still works with JavaScript off. --- Here you'll be building a sample product page! Build a small product catalog and a product page whose add-to-cart form is a plain HTML form. Then add an interactive quantity stepper with a live subtotal. Start from the project created in [Getting Started](/docs): ```bash proa new site shop-demo --template marketing --tailwind --yes cd shop-demo proa dev --open ``` `proa dev` builds the Tailwind assets before starting the server, runs the binary through the linked pipeline, and restarts on changes. Open tabs reload themselves after each rebuild, and stylesheet edits are applied without navigating. See [Live reload](/docs/cli/new-site#live-reload) for the flags. The generated site already has a home page, a `RootDocument` layout in `src/layouts/root.rs`, a router in `src/routes.rs`, and health endpoints. You will add files beside them and register new routes between the `proa:route-specs` markers. ## Target Files ```proa-filetree src/ components/ mod.rs product_card.rs quantity_stepper.rs data/ mod.rs products.rs endpoints/ mod.rs cart.rs layouts/ root.rs pages/ mod.rs products.rs product.rs rsjs_assets.rs routes.rs main.rs ``` ## Product Data Prices are whole dollars as `i64` so the browser can multiply them later without a money-formatting helper. ```rust #[derive(Debug, Clone, Copy)] pub struct Product { pub slug: &'static str, pub path: &'static str, pub name: &'static str, pub category: &'static str, pub price_usd: i64, pub summary: &'static str, } pub const PRODUCTS: &[Product] = &[ Product { slug: "atlas-jacket", path: "/products/atlas-jacket", name: "Atlas Jacket", category: "Men's Weatherproof Shell", price_usd: 248, summary: "Weatherproof shell with a quiet technical finish.", }, Product { slug: "field-pack", path: "/products/field-pack", name: "Field Pack", category: "Everyday Backpack", price_usd: 168, summary: "Structured everyday pack with laptop and camera storage.", }, Product { slug: "merino-tee", path: "/products/merino-tee", name: "Merino Tee", category: "Men's Base Layer", price_usd: 78, summary: "Lightweight base layer for travel, training, and daily wear.", }, ]; pub fn find_product(slug: &str) -> Option<&'static Product> { PRODUCTS.iter().find(|product| product.slug == slug) } ``` ```rust pub mod products; ``` ```rust // The generated main.rs declares each module explicitly. Add the new one: mod data; ``` ## Product Card The whole card is the link, and the image slot is a placeholder box until you have real photography. ```rust use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError}; use proa_macros::html_sync; use crate::data::products::Product; pub struct ProductCard { pub product: &'static Product, } impl WebRenderSync for ProductCard { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { let product = self.product; html_sync! {
{product.name}

{product.name}

{product.category}

"$"{product.price_usd}

} .render(cx) } } ```
```rust pub mod badge; pub mod button; pub mod card; pub mod product_card; pub use badge::Badge; pub use button::Button; pub use card::Card; ```
The scaffold's `mod.rs` already declares the vendored `Badge`, `Button`, and `Card`, which the home page uses, so append to it rather than replacing it. ## Routes Two pages: a listing that maps over `PRODUCTS`, and a detail page whose add-to-cart control is a native form posting `sku` and `qty`. There is no JavaScript in either one. Each handler wraps its page in the generated `RootDocument` layout and hands it to `RouteResponse::ssr_async`, the same shape as the generated `src/pages/home.rs`. `RootDocument` is async-capable, so a synchronous page goes in through `proa_core::to_async`. A loop body is its own template, so it needs its own `html_sync!` block. ```rust use axum::response::IntoResponse; use proa_core::{to_async, DataLoader, WebContext, WebRenderSync, WriteError}; use proa_framework_axum::RouteResponse; use proa_macros::html_sync; use crate::components::product_card::ProductCard; use crate::data::products::PRODUCTS; use crate::layouts::RootDocument; pub async fn handler() -> impl IntoResponse { RouteResponse::ssr_async(RootDocument { title: "Products", content: to_async(ProductsPage), }) } struct ProductsPage; impl WebRenderSync for ProductsPage { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { html_sync! {

"Products"

    {for product in PRODUCTS { html_sync! {
  • {ProductCard { product }}
  • } }}
} .render(cx) } } ```
```rust use axum::{extract::Path, http::StatusCode, response::IntoResponse}; use proa_core::{to_async, DataLoader, WebContext, WebRenderSync, WriteError}; use proa_framework_axum::RouteResponse; use proa_macros::html_sync; use crate::data::products::{find_product, Product}; use crate::layouts::RootDocument; pub async fn handler(Path(slug): Path) -> Result { let product = find_product(&slug).ok_or(StatusCode::NOT_FOUND)?; Ok(RouteResponse::ssr_async(RootDocument { title: product.name, content: to_async(ProductPage { product }), })) } struct ProductPage { product: &'static Product, } impl WebRenderSync for ProductPage { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { let product = self.product; html_sync! {
{product.name}

{product.name}

{product.category}

"$"{product.price_usd}

{product.summary}

"Free standard shipping on orders over $150."

"Free 60-day returns."

} .render(cx) } } ```
```rust pub mod home; pub mod product; pub mod products; ```
## Form Action Add the POST handler beside the generated health endpoints, then register all three routes in `src/routes.rs`. ```rust use axum::{extract::Form, response::Redirect}; use serde::Deserialize; #[derive(Deserialize)] pub struct AddToCart { sku: String, qty: u32, } pub async fn add_to_cart(Form(form): Form) -> Redirect { tracing::info!(sku = %form.sku, qty = form.qty, "add to cart"); Redirect::to("/products") } ``` ```rust pub mod cart; pub mod health; ``` ```rust use axum::routing::{get, post}; // ... inside router(), between the markers the generator maintains: let (router, metadata) = router_from_spec(vec![ // proa:route-specs:start Route::endpoint("/", get(pages::home::handler)), Route::endpoint("/products", get(pages::products::handler)), Route::endpoint("/products/:slug", get(pages::product::handler)), Route::endpoint("/cart", post(endpoints::cart::add_to_cart)), // proa:route-specs:end ]); ``` Two pieces of Axum vocabulary are worth naming here, because they look unusual the first time: - `Form` is a newtype wrapper, `struct Form(pub T)`. The type is the instruction: it tells Axum to parse the request body as `application/x-www-form-urlencoded` into `T`. Swap it for `Json`, `Path`, or `Query` to read a different part of the request. - `Form(form):` is ordinary Rust pattern destructuring in the parameter position, the same thing as `let Form(form) = ...` written inline. You can write `form: Form` instead and reach the value through `form.0`. Deserializing into a typed struct rather than a `HashMap` means a missing or non-numeric `qty` is rejected by the extractor with a 422 before your handler runs. Open `http://localhost:3000/products/atlas-jacket` and submit the form. It posts `sku=atlas-jacket` and `qty=1`, then redirects. No JavaScript has been involved so far. ## The Island Now make the quantity adjustable in the browser, without giving up anything above. ```toml rsjs = { version = "0.2", git = "https://github.com/Proa-Labs/proa.git" } ``` `rsjs` re-exports the `#[rsjs]` attribute, so no separate macro crate is needed, and the scaffold already depends on `serde`. ```rust use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError}; use proa_macros::html_sync; use rsjs::{rsjs, signal}; pub struct QuantityStepper { pub unit_price: i64, } #[rsjs(component, client)] impl WebRenderSync for QuantityStepper { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { let qty = signal(1_i64); let unit_price: rsjs::Signal = signal(self.unit_price); let subtotal = rsjs::computed(|| qty.get() * unit_price.get()); html_sync! {
"Quantity" "Subtotal $"{subtotal.get()}
{qty.get()}
} .render(cx) } } ```
```rust pub mod badge; pub mod button; pub mod card; pub mod product_card; pub mod quantity_stepper; pub use badge::Badge; pub use button::Button; pub use card::Card; ``` ```rust use crate::components::quantity_stepper::QuantityStepper; // ...
{QuantityStepper { unit_price: product.price_usd }}
```
Four things in `quantity_stepper.rs` carry the whole idea: - `#[rsjs(component, client)]` marks an otherwise ordinary `WebRenderSync` impl for analysis. `component` asks the compiler to analyze it; `client` grants permission to originate browser behavior. - `signal(1_i64)` is island-local state. `signal(self.unit_price)` seeds a prop into browser-visible state once, at mount. The explicit `rsjs::Signal` annotation gives the arithmetic a proven fixed-width integer domain, which the compiler requires before it will lower `*` into JavaScript; without it you get `rsjs/integer-domain-unproven`. - `rsjs::computed(...)` derives the subtotal. It recomputes in the browser when either input changes, and it is evaluated once on the server for the initial HTML. - The hidden input is the bridge back to the plain form. Its `value` is a reactive attribute, and the runtime writes the DOM `value` property, so the form submits the current quantity. In `product.rs` the stepper replaces the fixed hidden `qty` input. An island is invoked exactly like any other component: a struct literal in a slot. That is the component running in the demo at the top of this page. ## Serving the Island The compiler emits a JavaScript module per island and a shared runtime. A hand-written Axum app has to do two things with them: announce the ones a response actually used, and serve them. `RsjsRuntimeTags` must render as the last thing in ``. The snapshot only sees islands rendered so far, so these tags have to come after the page content. A page with no islands plans nothing and emits no tags. The `component_` fallback in `rsjs_assets.rs` matters: a component fragment is compiled separately from the island that mounts it, and the page preloads both. ```rust use proa_core::{to_async, WebRenderSync}; pub struct RsjsRuntimeTags; impl WebRenderSync for RsjsRuntimeTags { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { let artifacts = cx.rsjs_artifacts_snapshot(); let plan = rsjs::manifest() .page_runtime_plan(artifacts.rendered_islands()) .expect("every rendered island resolves in the linked manifest"); plan.write_html(&mut cx.out, None) } } // ... and in the generated document body, which is an async `html!` template: {content} {to_async(RsjsRuntimeTags)} ``` ```rust use axum::{ extract::Path, http::{header, StatusCode}, response::{IntoResponse, Response}, routing::get, Router, }; /// Generic over the app state so it merges into the generated `Router`. pub fn router() -> Router where S: Clone + Send + Sync + 'static, { Router::new() .route("/rsjs-runtime.js", get(runtime)) .route("/rsjs/island/:file", get(island)) } async fn runtime() -> impl IntoResponse { (js_headers(), rsjs::runtime_assets::CORE_JS) } async fn island(Path(file): Path) -> Response { let asset_id = file.strip_suffix(".js").unwrap_or(file.as_str()); let fragment_id = asset_id.strip_prefix("component_").unwrap_or(asset_id); let manifest = rsjs::manifest(); let source = manifest .islands .iter() .find(|entry| entry.asset_id == asset_id) .map(|entry| entry.js_source) .or_else(|| { manifest .component_fragments .iter() .find(|entry| entry.asset_id == asset_id || entry.asset_id == fragment_id) .map(|entry| entry.js_source) }); match source { Some(source) => (js_headers(), source).into_response(), None => (StatusCode::NOT_FOUND, "unknown rsjs asset").into_response(), } } fn js_headers() -> [(header::HeaderName, &'static str); 2] { [ (header::CONTENT_TYPE, "application/javascript; charset=utf-8"), (header::CACHE_CONTROL, "no-store"), ] } ``` ```rust mod rsjs_assets; ``` ```rust use crate::rsjs_assets; // ... in the chain after router_from_spec, before .with_state(state): router .route("/healthz", get(endpoints::health::live)) .route("/readyz", get(endpoints::health::ready)) .merge(rsjs_assets::router()) // proa:explicit-routes:start .route("/index.md", get(pages::home::markdown)) // proa:explicit-routes:end .nest_service("/static", static_assets::service()) .with_state(state) ``` ## Verify `proa dev` restarts on its own after the change; if you stopped it, run it again. Open `http://localhost:3000/products/atlas-jacket` and check three things. **The server sent working HTML.** View source. The stepper is real markup with the correct starting state, wrapped in an island boundary next to a small seed script: ```html
Quantity Subtotal $248
1
``` The `data-rsjs-*` attributes are compiler-owned markers that tell the runtime which nodes to bind on hydration. The seed script carries the exact initial signal values, so the browser rebuilds state without re-fetching anything. The minus button is already `disabled` because `qty` starts at 1. That state was computed on the server, not after hydration. **The island works.** Click `+` twice. The quantity reads `3`, the subtotal reads `$744`, the minus button re-enables, and the hidden input now submits `qty=3`. **It degrades.** Disable JavaScript in devtools and reload. The stepper buttons do nothing, but the hidden input still carries `value="1"`, so "Add to Bag" posts `sku=atlas-jacket` and `qty=1` exactly as it did before the island existed. Nothing on the page is blank or broken. That is the whole trade. The island is opt-in behavior layered onto HTML that already works, not the mechanism that makes the page appear. ## Validate ```bash cargo fmt --all cargo check proa fmt --check --verify src proa lint src ``` `cargo check` still works on a project with islands. `cargo build` and `cargo run` do not: the link step deliberately fails so that a binary is never shipped without its compiled island modules. Use `proa dev`, `proa run`, or `proa build` instead. ## Next | Add | Read | |-----|------| | More island shapes | [Islands: when and why](/docs/guides/islands) | | Signal semantics and props | [RSJS Signals](/docs/rsjs/signals) and [Component props](/docs/rsjs/component-props) | | Server data loading | [Framework Data Loading](/docs/framework/data-loading) | | Richer forms and typed actions | [Forms and actions](/docs/guides/forms-actions) | | Production cache rules | [Page caching](/docs/framework/page-caching) | --- # Docs: HTML rendering URL: /docs/core/html-rendering Description: Render typed Rust values to HTML with render traits and the html! macros. --- title: HTML rendering description: Render typed Rust values to HTML with render traits and the html! macros. keywords: [html_sync, html!, html_sync!, text!, template] --- Proa lets you use ordinary Rust for dynamic values, components, conditions, matches, and loops. You write templates with `html!` or `html_sync!`, then implement a render trait to turn the template into a component. Everything on this page renders on the server. That is not a mode you select per route or per component: a component writes bytes into the response, the reader gets a complete page, and nothing on it needs JavaScript to appear. There is no hydration pass by default, and a page ships no client JavaScript at all until you add an [island](/docs/guides/islands), which is opt-in per component. A wholly client-rendered SPA is mounted separately with `ExternalApp::csr_app`; it is not a Proa component render mode. ## Writing templates `html_sync!` and `html!` share the same template syntax. The examples in this section use `html_sync!` because most components do not need to await anything. ### DOM Elements Standard elements, attributes, dynamic values, and components share one tree: ```rust filename="src/components/hero.rs" {4-5} html_sync! {

"Hello World!"

"Count: "{self.count}

{Price { amount: self.price }}
} ``` Write attribute names with their HTML spelling: `aria-label`, `aria-labelledby`, and `data-component` stay kebab-case in the template and rendered DOM. Text nodes are string literals in double quotes, and Proa escapes them automatically. Characters like `<`, `>`, `&`, and non-breaking spaces are encoded per the [HTML serialization algorithm](https://html.spec.whatwg.org/multipage/parsing.html#serialising-html-fragments). Rust expressions go in curly braces. Values render as text, while components use rust `struct` syntax and render in place. ### Formatted text with `text!` Use a plain `{value}` slot for one dynamic value. Use `text!` when a text node mixes static text, multiple values, or Rust formatting directives: ```rust filename="src/components/block_gallery.rs" {5,9} html_sync! { } ``` `text!` escapes for its position: attribute escaping in `title`, and text-node escaping in the caption. Bare `{}` placeholders use Proa rendering; format specifiers such as `{:.2}` use Rust formatting. Write `{{` or `}}` for literal braces. Unlike `format!()`, `text!` writes directly to Proa's output buffer. This avoids allocating an intermediate `String` and then copying it into the response on every render, which reduces work in a hot render path. The Proa CLI is designed to catch this mistake. If you use `format!()` while rendering, `proa lint` reports the allocation and suggests a streaming alternative: ```console $ proa lint src/components/greeting.rs src/components/greeting.rs:4:17: warning[perf/format]: format!() in a Proa render path allocates a String on every render help: for text, render adjacent pieces like `"$"{price}" / night"`; for attrs, precompute or use `text!(...)` ``` Placeholder-bearing `text!` is rejected in URL attributes such as `href` and `src`; use a validated URL value or typed URL helper instead. ### Control flow (if, for, match, etc.) is Rust Inside an ordinary server-rendered component, a `{ ... }` slot accepts Rust expressions. Use `if`, `match`, and `for` directly in the render tree. When a branch or loop iteration produces markup, return another template fragment: ```rust filename="src/components/product_list.rs" {2-3,10,13,21} html_sync! {
{if self.products.is_empty() { html_sync! {

"No products found"

} } else { html_sync! {
    {for product in self.products { html_sync! {
  • {match &product.status { Status::Active => html_sync! { "Active" }, Status::Inactive => html_sync! { "Inactive" }, }} {ProductCard { product }}
  • } }}
} }}
} ``` The `class` conditional returns strings directly; branches that produce elements return template fragments. ### `Option` `Option` renders its value when it is `Some` and nothing when it is `None`, so optional content often needs no explicit branch: ```rust filename="src/components/panel.rs" html_sync! {

{self.title}

{self.subtitle}
} ``` > **RSJS note**: These examples show ordinary server-rendered components. > Expressions that must also compile into browser JavaScript inside an > RSJS-analyzed component use a deliberately portable subset of Rust. See > [Islands: when and why](/docs/guides/islands) before moving behavior to the > browser. ### Attributes Static values are string literals; dynamic values go in braces and are escaped before rendering: ```rust filename="src/components/link.rs" html_sync! { {self.children} } ``` `Option<&str>` omits the attribute entirely when `None`: ```rust filename="src/components/tab.rs" let selected: Option<&str> = self.selected.then_some("true"); html_sync! { } ``` Valueless attributes render as the bare attribute name (`disabled`, `open`), which is spec-compliant: ```rust filename="src/components/form.rs" html_sync! {
"Click"
} ``` The macro accepts **any** attribute name, so htmx, Alpine, ARIA, and `data-*` attributes work without a fixed allowlist. Prefer their canonical hyphenated HTML spelling; underscores are accepted and normalized to dashes when needed: ```rust filename="src/components/save.rs" html_sync! {
"content"
} ``` Prefer data attributes for JavaScript hooks and tests, and classes for styling. Never parse a generated class string as state. ### Documents and comments `` works inside the macro, and `<>...` renders siblings without a wrapper. Rust comments inside `html!` are stripped by the compiler and never reach the output. `///` doc comments do not work there, they are only valid before item definitions. There is no `` syntax. HTML comments in SSR output are almost always wasted bytes, so including one is deliberate: ```rust filename="src/layouts/root.rs" use proa_core::StaticRaw; html_sync! { {StaticRaw("")}
"content"
} ``` ## Choosing a render trait | Output | Synchronous | Async-capable | |--------|-------------|---------------| | HTML | `WebRenderSync` + `html_sync!` | `WebRender` + `html!` | | Markdown | `MdRenderSync` + `md_sync!` | `MdRender` + `md!` | Every component picks one of these two, and the choice is narrow: **use the synchronous trait unless the component itself has to `.await` while rendering.** Both render on the server; the difference is whether rendering can suspend. A synchronous component writes its bytes and returns. There is no future, no driver, and no scheduling. An async-capable component returns a `RenderOutcome` instead, which a driver at the route or adapter boundary resolves. Most route bodies, layouts, and UI components stay synchronous. `WebRenderSync` with `html_sync!`. This is the shape of almost every component you will write. ```rust filename="src/components/price.rs" use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError}; use proa_macros::html_sync; pub struct Price { pub amount: u32, } impl WebRenderSync for Price { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { html_sync! { "$"{self.amount} } .render(cx) } } ``` `WebRender` with `html!`. The signature is heavier because the returned future borrows the context, so reach for it only when you need to `.await`. ```rust filename="src/components/async_price.rs" use std::future::Future; use proa_core::{ DataLoader, RenderOutcome, WebContext, WebRender, WriteError, }; use proa_macros::html; pub struct AsyncPrice; impl WebRender for AsyncPrice { fn render( self, cx: &mut WebContext, ) -> RenderOutcome>> { RenderOutcome::pending(async move { let amount = 42_u32; html! { "$"{amount} } .render(cx) .resolve() .await }) } } ``` Ordinary authored implementations are generic only over `L: DataLoader`. `WebContext` owns the default reusable `RenderBuffer`; the second `B` parameter is reserved for lower-level custom-writer integrations. ## Driving an async render `WebRender` never blocks to render itself. It returns a `RenderOutcome`, `Done(result)` on the sync path or `Pending(future)` on the async path, and a driver at the route or adapter boundary resolves it. For framework routes, use `RouteResponse::ssr_async(...)`, `ssr_async_with_loader(...)`, or the `ssr_stream*` constructors. For custom adapters and tests, `proa_core` ships drivers: | Driver | Use it when | |--------|-------------| | `render_to_vec_async(root)` | You want an owned contiguous byte buffer. | | `render_to_string_async(root)` | You want a UTF-8 string and accept a panic on invalid UTF-8. | | `try_render_to_string_async(root)` | You want a UTF-8 string with explicit error handling. | | `render_into_async(&mut buf, root)` | You want to reuse a caller-owned `RenderBuffer`. | | `render_in_context_async(&mut cx, root)` | You need to customize `WebContext` first. | ```rust filename="src/adapter.rs" use std::sync::Arc; use proa_core::{render_in_context_async, WebContext}; let mut out = Vec::new(); let mut cx = WebContext::with_loader_buffer( &mut out, Arc::new(loader), ); render_in_context_async(&mut cx, PageWithData).await?; ``` > **Good to know**: Streaming render modes also need the matching framework state installed, stream recorders and chunk flushers. Use the framework response helpers unless you are writing that adapter layer. See [Streaming SSR](/docs/core/streaming-ssr). ## Next steps - [Components](/docs/core/components) - Nest components, pass children, and branch inside a render tree. - [Markdown rendering](/docs/core/markdown) - Render typed Rust values to Markdown for docs, feeds, and agents. - [WriteBuf](/docs/core/write-buf) - Pick the buffer a route renders into. - [Escaping and raw HTML](/docs/core/escaping) - How Proa encodes dynamic values, and the two ways to opt out. - [Route responses](/docs/framework/responses) - Return synchronous, buffered async, or streaming SSR with islands. --- # Docs: Components URL: /docs/core/components Description: How Proa components are plain structs, how props and children flow, and how the recipe system styles them. --- title: Components description: How Proa components are plain structs, how props and children flow, and how the recipe system styles them. keywords: [WebRenderSync, WebRender, to_async, await_component, ProaComponent] --- A Proa component is a struct plus a render impl. There is no component macro, no virtual DOM, and no runtime registry: the struct is the props, the impl is the template, and the compiler is the type check. You do not have to write the common ones yourself. [proa-ui](https://ui.proa.so/docs) is a shadcn-style catalog of Proa components, and `proa ui add button` vendors the source into `src/components/button/` in your project. You own the files and edit them exactly like the ones on this page. ```rust filename="src/components/button/button.rs" pub struct Button { pub variant: ButtonVariant, pub size: ButtonSize, pub disabled: bool, pub class: Option<&'static str>, pub children: Option, } ``` That declaration is the component's public API. Every field is a prop, `rustdoc` documents it for free, and passing an unknown one is a compile error rather than a silently ignored attribute. ## Rendering a component Put a struct literal in braces inside a template and it renders in place: ```rust filename="src/pages/home.rs" html_sync! {
{Button { children: Some("Save"), ..Button::default() }} {Button { variant: ButtonVariant::Outline, children: Some("Cancel"), ..Button::default() }}
} ``` Struct-literal syntax is doing real work here. `..Button::default()` is Rust's functional update, so a component with twelve props stays readable when a caller sets two, and the defaults live in one `Default` impl instead of being scattered across call sites. There is no separate props type to keep in sync, and nothing is boxed: the literal is constructed on the stack and consumed by `render`. ## Writing the render impl The impl decides which trait the component supports. Use `WebRenderSync` unless the component genuinely awaits: ```rust filename="src/components/button/button.rs" use proa_core::{DataLoader, WebContext, WebRenderSync, WriteError}; use proa_macros::html_sync; impl WebRenderSync for Button where L: DataLoader, C: WebRenderSync, { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { html_sync! { } .render(cx) } } ``` `render` takes `self` by value, so props move into the template with no clone and no borrow to satisfy. ## Children A component that wraps caller content is generic over its children, and the bound is the same render trait the parent implements: ```rust impl WebRenderSync for Button where C: WebRenderSync, ``` `C = ()` in the struct definition supplies the default, so `Button::default()` works without a turbofish for a button with no children. The render bound constrains only the child type; other generics on the struct stay unconstrained. Children are `Option` because `Option` renders nothing when `None`. Optional content needs no branch in the template: ```rust filename="src/components/panel.rs" html_sync! {
{self.subtitle} {self.children}
} ``` ## Styling: recipes, not class strings Proa is opinionated about where classes live. A component does not build a class string at render time; it selects between static fragments that were resolved at compile time. The convention, borrowed from shadcn/ui's variant vocabulary and Panda CSS's recipe model, splits every component into two files. `recipe.rs` owns the styling. Each variant axis is an enum whose `classes()` maps a semantic name to a class fragment: ```rust filename="src/components/button/recipe.rs" mod base { pub const BUTTON_BASE: &str = "inline-flex items-center justify-center rounded-md text-sm font-medium"; } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum ButtonVariant { #[default] Solid, Outline, Ghost, } impl ButtonVariant { pub const fn classes(self) -> &'static str { match self { Self::Solid => "bg-primary text-primary-foreground hover:bg-primary/90", Self::Outline => "border border-input bg-background hover:bg-accent", Self::Ghost => "hover:bg-accent hover:text-accent-foreground", } } pub const fn as_str(self) -> &'static str { match self { Self::Solid => "solid", Self::Outline => "outline", Self::Ghost => "ghost", } } } ``` Both methods are `const fn`, so a variant is a compile-time selection between `&'static str` fragments. The paired `as_str` is what the component renders into `data-variant`. One function composes the axes, returning a stack-allocated [`ClassList`](/docs/core/class-composition) rather than a `String`: ```rust filename="src/components/button/recipe.rs" pub fn button_classes( variant: ButtonVariant, size: ButtonSize, class: Option<&'static str>, ) -> ClassList<4> { ClassList::new() .add(base::BUTTON_BASE) .add(variant.classes()) .add(size.classes()) .add_opt(class) } ``` `add_opt` is the escape hatch: callers extend a component with `class: Some("w-full")` without the component owning every combination. Rendering `Button { children: Some("Save"), ..Button::default() }` produces: ```html ``` Two conventions show up in that output and both are deliberate: - **`data-button`** marks the component root. Every component renders a bare `data-` attribute so tests and browser automation can select it without depending on class strings. - **`data-variant` / `data-size`** publish the resolved variant. Styling reads from `class`, but assertions, visual regression tooling, and CSS overrides read the semantic value. ## One component, two output targets A component is not tied to HTML. Implement `MdRenderSync` on the same struct and it renders into a Markdown response as well, which is how a route serves HTML to browsers and Markdown to agents from a single component tree. Most interactive chrome has no Markdown equivalent, so the Markdown impl usually renders the meaning rather than the widget. A button is its label: ```rust filename="src/components/button/md.rs" use proa_core::{DataLoader, MdRenderSync, WebContext, WriteError}; /// Button renders as just its label text; the visual chrome is meaningless in Markdown. impl> MdRenderSync for Button { fn render_md(self, cx: &mut WebContext) -> Result<(), WriteError> { let prev = cx.md_set_inline(true); self.children.render_md(cx)?; cx.md_set_inline(prev); Ok(()) } } ``` `md_set_inline(true)` marks the span as inline so nested block components degrade instead of injecting newlines into a table cell or a link label. It returns the previous value, which the impl restores rather than assuming the context started inline. A component that emits block-level Markdown (a heading, a table, a list) has the opposite obligation: call `cx.md_block_boundary()` before its first block byte and after its last, because it cannot know what its previous sibling wrote. `md_block_boundary` is a no-op in inline context, so the same impl stays correct in both. > **Good to know**: The two impls are independent. A component can implement `WebRenderSync` only, both, or add `WebRender` when it needs to `.await`. See [HTML rendering](/docs/core/html-rendering) for the sync and async split, and [Markdown rendering](/docs/core/markdown) for the Markdown macros. ## Crossing the sync and async boundary A sync-only component composes into an async tree through `to_async(...)`: ```rust filename="src/pages/dashboard.rs" html! {
{proa_core::to_async(Button { children: Some("Save"), ..Button::default() })}
} ``` When a future resolves to a renderable child, use `await_component(...)` instead. An async child cannot appear inside `html_sync!` at all: move the boundary up to `WebRender` and `html!`, or await the data before rendering. ## How proa-ui scales this The [proa-ui](https://ui.proa.so/docs) component library runs the same model with two additions that matter once a component has several independent style axes. **A resolver instead of a helper function.** Props arrive as `Option`, and a const recipe resolves them against defaults into one `Copy` style value: ```rust filename="proa_ui/src/components/button/recipe.rs" pub const BUTTON_RECIPE: ButtonRecipe = ButtonRecipe { base: base::BUTTON_BASE, size_default: Some(ButtonSize::Md), color_default: Some(ButtonColor::Primary), variant_default: Some(ButtonVariant::Solid), radius_default: Some(ButtonRadius::Md), }; ``` `BUTTON_RECIPE.resolve(props)` returns a `ButtonStyle` that implements `AttrValue`, so it streams its class fragments straight into the response buffer with no intermediate `ClassList` or `String`. It also carries the effective values, which is what feeds `data-size`, `data-color`, `data-variant`, and `data-radius`. **Compound variants.** Independent axes concatenate, but `color x variant` does not: an outline destructive button is not "destructive classes plus outline classes". proa-ui resolves that pair in one place, `color.classes_for(variant)`, rather than layering two fragments and hoping the cascade agrees. The library also derives `#[derive(ProaVariant)]` on its variant enums to publish variant metadata (labels, values, classes) for its catalog and TypeScript codegen. That derive expands to an impl of proa-ui's own `VariantSpec` trait, so it belongs to the library's infrastructure rather than to application components; a component in your app uses plain enums exactly as shown above. ## Next steps - [HTML rendering](/docs/core/html-rendering) - Elements, attributes, control flow, and the sync and async render traits. - [Class composition](/docs/core/class-composition) - `ClassList`, capacity, and the allocation to avoid in a render path. - [Markdown rendering](/docs/core/markdown) - The `md_sync!` and `md!` template macros behind an `MdRenderSync` impl. - [Islands: when and why](/docs/guides/islands) - Add browser behavior to a component with RSJS. --- # Docs: Markdown rendering URL: /docs/core/markdown Description: Compose Markdown from typed components, as a first-class interface for humans and agents. --- title: Markdown rendering description: Compose Markdown from typed components, as a first-class interface for humans and agents. --- 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](https://arxiv.org/abs/2411.02959). 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](https://www.sanity.io/blog/how-to-serve-content-to-agents-a-field-guide). 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. ```rust filename="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 MdRenderSync for ProductSummary { fn render_md(self, cx: &mut WebContext) -> 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](/docs/guides/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 | Trait | Template macro | Use it when | |-------|----------------|-------------| | `MdRenderSync` | `md_sync!` | The component writes Markdown without `.await`. | | `MdRender` | `md!` | The component may await, or composes async Markdown children. | HTML and Markdown share `WebContext`. Both Markdown traits default to `L = NoLoader` and `B = RenderBuffer`, in that order. Application components usually implement `MdRenderSync` or `MdRender` and take `&mut WebContext`; 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: ```rust filename="src/pages/summary.rs" use proa_macros::md_sync; let summary = md_sync! { r###" # {title} {description} "### }; ``` Use `md!` when an interpolated child awaits: ```rust filename="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: ```rust filename="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](/docs/core/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: ```rust filename="src/components/listing.rs" impl MdRenderSync for &Listing { fn render_md(self, cx: &mut WebContext) -> 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: ```rust filename="src/pages/search.rs" impl MdRenderSync for &SearchPage { fn render_md(self, cx: &mut WebContext) -> Result<(), WriteError> { md_sync! { r###" # {self.title} {self.listings.len()} stays --- {for listing in self.listings.iter() { {listing} }} "### } .render_md(cx) } } ``` ```md filename="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: ```rust filename="src/components/callout.rs" impl WebRenderSync for &Callout { fn render(self, cx: &mut WebContext) -> Result<(), WriteError> { html_sync! { } .render(cx) } } impl MdRenderSync for &Callout { fn render_md(self, cx: &mut WebContext) -> Result<(), WriteError> { md_sync! { r###" > **{self.label}:** {self.body} "### } .render_md(cx) } } ``` One value, rendered two ways: ```html filename="Accept: text/html" ``` ```md filename="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](/docs/framework/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: ```rust filename="src/components/section.rs" impl MdRenderSync for Heading { fn render_md(self, cx: &mut WebContext) -> 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. ```md filename="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: ```rust filename="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: ```rust filename="src/components/callout.rs" use proa_macros::html_sync; cx.render_html_in_md(html_sync! { })?; ``` `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](/docs/core/components); everything there applies with `md_sync!` in place of `html_sync!`. ## Data loading Markdown uses the same `DataLoader` API as HTML. An `MdRender` 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` can participate in the same tree, but cannot await a load. See [Data loading](/docs/framework/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`: ```rust filename="src/pages/report.rs" use std::rc::Rc; use proa_core::StreamChunkFlusher; use proa_stream::render_md_in_order_to_flusher; let flusher: Rc = 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 - [HTML rendering](/docs/core/html-rendering) - Render typed Rust values to HTML with render traits and the html! macros. - [Components](/docs/core/components) - Nest components, pass children, and branch inside a render tree. - [Streaming SSR](/docs/core/streaming-ssr) - Flush the page shell first and stream slow sections as they resolve. - [Sink](/docs/core/sink) - Render streamed output into recycled pages instead of fresh allocations. - [Content negotiation](/docs/framework/content-negotiation) - Serve one resource as HTML for humans and Markdown for agents. --- # Docs: WriteBuf URL: /docs/core/write-buf Description: Pick the buffer a route renders into. --- title: WriteBuf description: Pick the buffer a route renders into. keywords: [WriteBuf, RenderBuffer] --- All web rendering writes into a `WriteBuf`. The buffer controls allocation behaviour, capacity handling, and how the response is finalized. Use `RenderBuffer` unless you have a reason not to. ```rust filename="src/pages/about.rs" use proa_core::{WebContext, WebRenderSync}; let mut cx = WebContext::new(); page.render(&mut cx)?; let html = cx.into_output().into_string(); ``` ## Choosing a buffer | Buffer | Use when | |--------|----------| | `RenderBuffer` | Routes, tests, and adapters. The default. | | `ZeroCopyBuf` | A custom transport experiment preserves large static byte slices. | | `StreamingZeroCopyBuf` | You are building a manual checkpoint-based streaming response. | | `SinkWriter` | A legacy adapter explicitly selected the deprecated page-pool writer. See [Sink](/docs/core/sink). | `RenderBuffer` grows by chaining reusable 4 KB pages, never relocates bytes it has already written, and recycles pages after `clear()`. The last two rows belong to the streaming path. [Sink](/docs/core/sink) covers them. ## Reusing a buffer For a long-lived service, keep the writer request-local or worker-local and clear it between renders: ```rust filename="src/render_worker.rs" use proa_core::{RenderBuffer, WebContext, WebRenderSync, WriteBuf}; pub struct RenderWorker { out: RenderBuffer, } impl RenderWorker { pub fn render_page

(&mut self, page: P) -> Result<&RenderBuffer, proa_core::WriteError> where P: WebRenderSync, { self.out.clear(); let mut cx = WebContext::with_buffer(std::mem::take(&mut self.out)); let result = page.render(&mut cx); self.out = cx.into_output(); result?; Ok(&self.out) } } ``` The caller flattens or streams the returned slices before the next `clear()`. For most Axum handlers, creating a fresh `RenderBuffer` per request and calling `into_string()` is also fine. Reuse matters when profiling shows allocation churn in a high-throughput render loop. > **Good to know**: Fixed-capacity writers and contiguous byte-vector helpers exist for low-level tests, benchmarks, and narrow adapters. Normal routes should not depend on guessing an output size. ## Context state `WebContext` carries the output buffer plus request-scoped render state: escape mode and dialect, request metadata and route params, an optional data loader, streaming recorders and flushers, and RSJS island usage records. Components treat `cx` as the place to render children and write output. Route and framework code choose the buffer and finalize the response. When the render tree needs request-scoped data loading: ```rust filename="src/pages/blog.rs" use std::sync::Arc; use proa_core::WebContext; let mut cx = WebContext::with_loader(Arc::new(loader)); ``` ## At the route boundary `ZeroCopyBuf` stores large static template slices as static parts and dynamic bytes in an owned buffer. Reach for it only when a custom transport preserves scatter-gather response structure instead of flattening immediately: ```rust filename="src/pages/home.rs" use proa_core::{WebContext, WebRenderSync, ZeroCopyBuf}; let mut cx = WebContext::with_buffer(ZeroCopyBuf::with_capacity(128, 16 * 1024)); page.render(&mut cx)?; let out = cx.into_output(); ``` This is the advanced custom-writer path: the page tree must explicitly support `WebRenderSync`. If you flatten the result right away, `RenderBuffer` and the default `WebRenderSync` form are simpler. Using `proa_framework_axum` directly? Return `RouteResponse::ssr(Page)` from the handler to use the standard `RenderBuffer` path. See [Route responses](/docs/framework/responses). Before you ship: use `RenderBuffer` for app routes and tests, call `render_static` first for docs routes, keep response finalization at the route boundary, and reach for zero-copy or sink-backed buffers only when the transport path actually uses them. ## Next steps - [Sink](/docs/core/sink) - Render streamed output into recycled pages instead of fresh allocations. - [Route responses](/docs/framework/responses) - Return synchronous, buffered async, or streaming SSR with islands. - [Streaming SSR](/docs/core/streaming-ssr) - Flush the page shell first and stream slow sections as they resolve. - [Writing performant pages](/docs/guides/performance) - How to write zero-allocation Proa pages that render in microseconds. Covers format!() avoidance, SafeText, ClassList, numeric rendering, buffer selection, and pre-formatting patterns. --- # Docs: Route responses URL: /docs/framework/responses Description: Return synchronous, buffered async, or streaming SSR with islands. --- title: Route responses description: Return synchronous, buffered async, or streaming SSR with islands. --- Handlers return `RouteResponse` when they want Proa's framework finalizer to render a component and inject island/bootstrap data. Use ordinary Axum responses for JSON APIs, form handlers, early status codes, redirects, and handler-level errors. See [Route handlers](/docs/framework/route-handlers) for endpoint patterns and [Status, redirects, and errors](/docs/framework/errors) for 404 pages, response headers, streaming constraints, and public error bodies. ## Synchronous SSR Use `RouteResponse::ssr` for ordinary `WebRenderSync` components. ```rust use proa_framework_axum::RouteResponse; pub async fn handler() -> RouteResponse { RouteResponse::ssr(HomePage) } ``` Write ordinary page components as `WebRenderSync`; the framework supplies the owned `RenderBuffer` response writer. ## Async SSR Use `RouteResponse::ssr_async` for components that implement `WebRender` and do not use a loader. The full render completes before the response begins, so root errors, status, and headers remain available to the HTTP layer. ```rust pub async fn handler() -> RouteResponse { RouteResponse::ssr_async(PageWithAwait) } ``` For typed data loaders, return `impl IntoResponse` and pass the loader explicitly: ```rust use std::sync::Arc; pub async fn handler() -> impl axum::response::IntoResponse { let loader = Arc::new(MyLoader::new()); RouteResponse::ssr_async_with_loader(Page, loader) } ``` Prefer typed loaders when the concrete loader type is known. Use the `*_dyn` constructors only at real dynamic boundaries. See [Data loading and streaming](/docs/framework/data-loading) for loader keys, cache hints, request-local dedupe, and preloading. ## Streaming SSR Use `ssr_stream` or `ssr_stream_with_loader` when the page should flush work incrementally. ```rust pub async fn handler() -> impl axum::response::IntoResponse { let loader = Arc::new(MyLoader::new()); RouteResponse::ssr_stream_with_loader(StreamedPage, loader) } ``` Streaming responses default to out-of-order chunked rendering. Use `with_render_mode` when a route needs a different render mode. Use [Data loading and streaming](/docs/framework/data-loading) for `Suspense` boundaries, `PreparedLoader`, and streamed data patterns. `RouteResponse::ssr_stream*` is the HTML response surface. For generated `text/markdown` responses, use `proa_stream::render_md_in_order_to_flusher` or `InOrderMarkdownDriver` from a custom handler/adapter and set the response content type yourself. See [Streaming Markdown Output](/docs/framework/data-loading#streaming-markdown-output). ## Responses outside the render pipeline Already-rendered HTML is an ordinary HTTP response, not a render plan. Return Axum's `Html` response directly: ```rust use axum::response::Html; pub async fn handler() -> Html<&'static str> { Html("

Already rendered
") } ``` If a compile-time HTML fragment must participate in Proa finalization or browser navigation, treat it as a renderable node instead of a response kind: ```rust RouteResponse::ssr(proa_core::static_html(b"
Static fragment
")) ``` Mount a browser-rendered application with `ExternalApp::csr_app`, which owns SPA fallback and asset routing. Keeping these cases out of `RouteResponse` makes the type describe one job: deferred server rendering. ## Islands Attach islands to SSR responses with `with_islands`. ```rust pub async fn handler() -> RouteResponse { RouteResponse::ssr(ProductPage) .with_islands(["CartIsland"]) } ``` The island names must exist in the `IslandManifest` passed to `FrameworkBuilder::new`. --- # Docs: Streaming SSR URL: /docs/core/streaming-ssr Description: Flush the page shell first and stream slow sections as they resolve. --- title: Streaming SSR description: Flush the page shell first and stream slow sections as they resolve. keywords: [Suspense, streaming, out-of-order] --- Streaming sends the parts of a page that are ready before the parts that are not. The reader sees the shell immediately; a slow section arrives when its data resolves. Wrap the slow part in `Suspense` and give it a fallback: ```rust filename="src/pages/product.rs" html! {

{self.product.name}

{Suspense::new( Reviews { id: self.product.id }, html_sync! {
}, )}
} ``` Everything outside the boundary flushes at once. Everything inside streams in behind it. ## When streaming helps Streaming trades total time for time-to-first-byte. It helps when one section is meaningfully slower than the rest of the page, and hurts when you split a page that was already fast. | Situation | Do this | |---|---| | One slow query, rest of the page is cheap | Wrap the slow section in `Suspense` | | Every section is slow | Fix the data layer first; streaming will not hide it | | The whole page is fast | Do not stream. The shell flush costs a round trip | | A bot or crawler is reading | Serve the buffered response; streaming helps humans, not parsers | Streaming also fixes the response head before the root component renders. Set status codes and headers in the handler before returning `RouteResponse::ssr_stream`; render-tree mutations are refused. See [Status, redirects, and errors](/docs/framework/errors). ## Returning a streaming response Use the framework response helpers rather than driving the stream yourself: ```rust filename="src/pages/product.rs" use proa_framework_axum::RouteResponse; RouteResponse::ssr_stream(ProductPage { id }) ``` The framework installs the stream recorder and the chunk flusher, then renders the root through them. [Route responses](/docs/framework/responses) covers the full set of constructors. ## In-order and out-of-order Two boundaries on one page, one slow and one fast: ```rust filename="src/pages/product.rs" html! {

"Product"

{Suspense::new(Reviews { id }, html_sync! {

"loading reviews"

})} {Suspense::new(Stock { id }, html_sync! {

"loading stock"

})}
} ``` Reviews takes 40 ms and comes first in the document. Stock takes 5 ms and comes second. The two modes resolve that conflict differently: ```text time ─────────────────────────────────────────────────────────────► 0 ms 5 ms 40 ms out-of-order shell stock reviews flushed sent the moment sent when it it resolves resolves in-order shell reviews, stock flushed stock was ready at 5 ms and waited ``` Out-of-order sends each boundary as it resolves. In-order preserves document order, so a slow boundary holds up every boundary behind it. That difference is visible on the wire. Out-of-order writes the fallback between comment markers, then sends each boundary later as a `