ferrovue 0.9.0

Vue templates compiled to Rust render functions: server-side rendering with no JavaScript at run time
Documentation
Vue single-file components compiled to Rust render functions.

ferrovue lets a Rust server render a Vue 3 component's HTML with no JavaScript at run time, while
the browser hydrates that HTML with the same `.vue` file. It has two halves, released together
under the same version number:

| | What it is | Where |
|---|---|---|
| The compiler | Reads `<script setup lang="ts">` components and writes one Rust module per component (TypeScript, runs on Node) | npm: [`ferrovue`]https://www.npmjs.com/package/ferrovue |
| The runtime | This crate: what the generated modules call, from escaping to `<RouterLink>` matching | crates.io: [`ferrovue`]https://crates.io/crates/ferrovue |

```text
 Greeting.vue ──(ferrovue compiler, Node)──▶ greeting.rs ──(your Rust server)──▶ HTML ──▶ Vue hydrates it
```

# The guarantee

ferrovue doesn't interpret templates. It takes what `@vue/compiler-sfc` compiles a template to for
server rendering, straight-line `_push` calls plus a few `@vue/server-renderer` helpers, and
translates that small, closed vocabulary into Rust that writes **the same bytes**. Vue still decides
element structure, attribute order, whitespace and fragment markers.

A single differing byte would be a hydration mismatch in the browser, so anything the compiler
can't translate exactly is a compile error that names the component and the construct. It never
guesses. The repository's conformance suite renders every fixture with Vue's `renderToString` and
with the generated Rust and compares the bytes; Vue then hydrates the recorded HTML with no
mismatch. A randomised differential tester does the same with components nobody wrote by hand.

# Quick start

The [guide](crate::guide) covers each step in detail; this is the whole path in brief.

**1. Install both halves.** The generated structs derive `serde::Serialize`, and the generated
`mod.rs` has a test-only helper that reads props from JSON.

```sh
pnpm add -D ferrovue                          # or npm / yarn
cargo add ferrovue serde --features serde/derive
cargo add --dev serde_json
```

**2. Configure.** `ferrovue.config.json` at the project root names the components and where the
Rust goes. Everything in `out` is replaced on each run.

```json
{
  "components": "client/components",
  "out": "src/generated"
}
```

**3. Write a component.**

```vue
<!-- client/components/Greeting.vue -->
<script setup lang="ts">
defineProps<{ name: string; unread: number; note?: string }>();
</script>

<template>
  <p class="greeting">Hello, {{ name }}!<b v-if="unread">{{ unread }} new</b><i v-if="note">{{ note }}</i></p>
</template>
```

**4. Generate.**

```sh
pnpm ferrovue           # writes src/generated/greeting.rs and src/generated/mod.rs
pnpm ferrovue --check   # in CI: exits 1 if the committed modules are stale
```

**5. Include the modules and render.** Declare the module once, with `#[rustfmt::skip]` so that
`ferrovue --check` keeps comparing the files byte for byte:

```rust
#[rustfmt::skip]
# /*
mod generated;
# */
# mod generated {}
```

Each component's module has a `Props` struct, built with `new` and a setter per optional prop, and
three ways to render:

```rust
# mod generated { pub mod greeting {
# // @generated by ferrovue from components/Greeting.vue. Do not edit: change the `.vue` file and run
# // `ferrovue`.
#
# use std::borrow::Cow;
#
# use ferrovue as fv;
#
# /// The component's name, as `data-island` carries it.
# pub const NAME: &str = "Greeting";
#
# /// The props `Greeting.vue` declares.
# #[derive(Debug, Clone, serde::Serialize)]
# #[cfg_attr(test, derive(serde::Deserialize))]
# pub struct Props<'a> {
#     #[serde(rename = "name")]
#     pub name: Cow<'a, str>,
#     #[serde(rename = "unread")]
#     pub unread: i64,
#     #[serde(rename = "note", default, skip_serializing_if = "Option::is_none")]
#     pub note: Option<Cow<'a, str>>,
# }
#
# impl<'a> Props<'a> {
#     /// Props with its required fields, every optional one absent.
#     pub fn new(name: impl Into<Cow<'a, str>>, unread: i64) -> Self {
#         Props { name: name.into(), unread, note: None }
#     }
#
#     /// Set `note`, which is absent otherwise.
#     pub fn note(mut self, note: impl Into<Cow<'a, str>>) -> Self {
#         self.note = Some(note.into());
#         self
#     }
# }
#
#
# /// Write the component's server render into `out`.
# pub fn render(out: &mut String, props: &Props<'_>) {
#     out.reserve(70 + props.name.len() + props.note.as_deref().map_or(0, str::len));
#     out.push_str("<p class=\"greeting\">Hello, ");
#     fv::escape_into(out, &props.name);
#     out.push('!');
#     if props.unread != 0 {
#         out.push_str("<b>");
#         fv::push_int(out, props.unread);
#         out.push_str(" new</b>");
#     } else {
#         out.push_str("<!---->");
#     }
#     if let Some(n1) = props.note.as_deref().filter(|v| !v.is_empty()) {
#         out.push_str("<i>");
#         fv::escape_into(out, n1);
#         out.push_str("</i>");
#     } else {
#         out.push_str("<!---->");
#     }
#     out.push_str("</p>");
# }
#
# /// The component's markup, for a maud page that shows it without hydrating it.
# pub fn html<'p, 'a>(props: &'p Props<'a>) -> fv::Html<'p, Props<'a>> {
#     fv::Html::markup(props, render)
# }
#
# /// The component as an island the client hydrates.
# pub fn island<'p, 'a>(props: &'p Props<'a>) -> fv::Html<'p, Props<'a>> {
#     fv::Html::island(NAME, props, render)
# }
# } }
use generated::greeting::{self, Props};

// `new` takes the required props; each optional one has a setter.
let props = Props::new("Ada", 3).note("welcome back");

// The markup alone, which the client never hydrates.
let html = greeting::html(&props).into_string();
assert_eq!(html, r#"<p class="greeting">Hello, Ada!<b>3 new</b><i>welcome back</i></p>"#);

// An island: the same markup in an element the client mounts on, with the props as JSON.
let island = greeting::island(&props).into_string();
assert!(island.starts_with(r#"<div data-island="Greeting" data-props="{&quot;name&quot;:&quot;Ada&quot;"#));

// Or straight into a buffer you already hold: no allocation of its own.
let mut page = String::from("<!doctype html><body>");
greeting::render(&mut page, &props);

// Every interpolated value is escaped.
let hostile = greeting::html(&Props::new("<script>", 0)).into_string();
assert_eq!(hostile, r#"<p class="greeting">Hello, &lt;script&gt;!<!----><!----></p>"#);
```

A component with `<style scoped>` writes the same `data-v-` ids as Vue's server renderer, computed
as `@vitejs/plugin-vue` computes them; see [`guide::scoped_styles`](crate::guide::scoped_styles).

On the client, the same component hydrates the page: `mountIslands` from `ferrovue` mounts
every island, `mountPage` hydrates a layout and the parts a [`Page`](crate::Page) recorded in its
slots, or the page's own Vue app hydrates the whole document. See
[`guide::islands_and_hydration`](crate::guide::islands_and_hydration).

# What the generated code calls

You rarely call most of this crate yourself; the generated modules do. The parts you do touch are
the values a render needs besides its props.

| You build | To render a component that |
|---|---|
| [`Slot`], a generated `Slots` struct, scoped-slot closures | renders `<slot>` or `<RouterView>` |
| [`Router`] from the generated `route_table::router(`), then a [`Route`] per request | renders `<RouterLink>` or reads `useRoute()` |
| A generated `stores::Stores` | reads Pinia state |
| [`I18n`] from the generated `i18n::i18n(locale`) | calls `$t` or `useI18n()` |
| [`Teleports`] | renders `<Teleport>` |
| A generated `provides::Provides`, empty or holding what the client app gives `app.provide` | calls `provide` or `inject`, or renders one that does ([`guide::provide_inject`]crate::guide::provide_inject) |
| [`Head`] | calls `useHead` or `useSeoMeta`, or renders one that does ([`guide::head`]crate::guide::head) |
| [`BasicHtml`], [`InlineHtml`], `Sanitised` (the `ammonia` feature), or your own type implementing [`TrustedHtml`] | renders `v-html` |
| [`JsDate`], `chrono::DateTime<Utc>` (the `chrono` feature), or your own type implementing [`JsTime`] | takes a `Date` prop ([`guide::dates_and_prices`]crate::guide::dates_and_prices#date-props) |

A prop declared `Record<string, T>` is a [`Record`], which keeps JavaScript's order of keys.

and these, to assemble a page:

- [`Html`]: a component applied to its props, borrowed (`html(&props)`) or held
  (`into_html(props)`), written into a buffer with [`Html::render_to`] or returned with
  [`Html::into_string`];
- [`Html::hydrate`] with a [`Hydrate`]: an island that hydrates once visible, when the browser is
  idle, on interaction or when a media query matches;
- [`Head::render`]: the tags and attributes of the page's `<head>`, `<html>` and `<body>`;
- [`state_script_into`]: the stores' state for the client to hydrate from;
- [`hole`] and [`split_holes`]: render a layout once and stream its parts;
- `HtmlStream` (the `stream` feature): a page with holes as an HTTP body, each hole's content
  sent as soon as it is ready;
- [`escape_into`]: text written into your own markup as Vue would write it.

and, in tests, [`conformance!`] and [`check_fixtures`], which render an application's fixtures
through its generated code and compare the bytes with the HTML recorded from Vue
([`guide::testing`](crate::guide::testing)).

The rest are Rust twins of JavaScript and Vue routines that generated code calls so that it writes
what Vue writes. Those a hand-written twin or page may call too are documented here:

- numbers: [`push_int`], [`push_number`], [`Js`], [`js_to_fixed`], [`js_to_precision`],
  [`js_to_exponential`], [`js_round`], [`js_sign`], [`js_max`] and [`js_min`];
- URIs: [`js_encode_uri_component`] and [`js_encode_uri`];
- Vue's own: [`slot_into`], [`attrs_into`], [`passed_attrs_into`] and [`query_into`].

The others are `#[doc(hidden)]`: JavaScript's string methods counted in UTF-16 code units
(`js_length`, `js_slice`, `js_split` and the rest the [`strings`](crate::guide::strings) page
lists), `Number`, `parseInt`, `parseFloat` and `JSON.stringify` (`js_number`, `js_parse_int`,
`js_parse_float`, `js_json_number`, `js_json_string`), an optional value written as `String()`
writes it (`JsOr`), dates as time values in UTC
(`js_date_parse`, `js_time_clip`, `js_to_iso_string` and `js_utc_full_year` to
`js_utc_milliseconds`), a `Date` prop handed to a child and written into `data-props`
(`js_time_of`, `js_date_prop`, `date_prop`, `opt_date_prop`), a list's `slice`, `at`, `reduce` and `toSorted` (`js_slice_range`, `js_slice_items`, `js_item_at`, `js_reduce`, `js_sorted`, `js_sorted_by_string`, `js_order`), and Vue's class, style, scope id, slot outlet, teleport and `v-html` helpers
(`class_into`, `class_object`, `class_names`, `style_text_into`, `merge_props`, `scope_attrs`,
`slot_into_slotted`, `scoped_slot_into`, `scoped_slot_into_slotted`, `teleport_into`,
`trusted_into`). Only generated code calls them, so their signatures follow the version of
generated code (the `__compat!` check at the top of a generated `mod.rs`) and may change in any
release; the [`generated_code`](crate::guide::generated_code#what-is-stable) page says what is
stable.

# Feature flags

| Feature | Default | What it adds |
|---|---|---|
| `router` | on | [`Router`], [`Route`], [`Link`], [`Query`], [`RouteDef`] and [`query_into`], for components that render `<RouterLink>` or `<RouterView>` or read `useRoute()`, from the `ferrovue-router` crate |
| `i18n` | on | The [`i18n`] module and [`I18n`], for components that call `$t` or `useI18n()`, from the `ferrovue-i18n` crate |
| `maud` | off | `impl maud::Render for Html`, so `(greeting::html(&props))` can go straight into a `maud::html!` page |
| `stream` | off | `HtmlStream`: a page rendered with holes as a stream of byte chunks, each hole filled by a future |
| `axum` | off | `stream`, and `IntoResponse` for `Html` and `HtmlStream`, so a handler returns `greeting::into_html(props)` ([`guide::web_frameworks`]crate::guide::web_frameworks) |
| `actix-web` | off | `stream`, and `Responder` for `Html` and `HtmlStream` |
| `ammonia` | off | `Sanitised`, HTML cleaned by ammonia, to name as `trustedHtml` for `v-html` props ([`guide::escaping`]crate::guide::escaping shows a policy of your own, with `ammonia` 4 as your own dependency) |
| `chrono` | off | `JsTime` for `chrono::DateTime<Utc>`, to name as `dateType` for `Date` props, `From<DateTime<Utc>>` for [`JsDate`], and `TryFrom<JsDate>` for `DateTime<Utc>`, whose error, `DateOutOfRange`, is an invalid date or one beyond chrono's ±262,142 years ([`guide::dates_and_prices`]crate::guide::dates_and_prices#chrono) |
| `dioxus` | off | The `dioxus` module: `Html::to_element` and `impl IntoDynNode for Html`, so `{greeting::island(&props)}` can go straight into Dioxus 0.7's `rsx!`, and `dioxus::state_script`; the guide's `dioxus` page shows a page |

An application with neither routes nor translations can turn the default features off
(`default-features = false`) and builds neither `ferrovue-router` nor `ferrovue-i18n`. Generated
code is the same either way: it names the router and translations only when the project configures
them.

# Crates

The runtime is four crates, released together at one version; this one re-exports the others at
its root, which is the one path generated code and this documentation use:

| Crate | What it holds |
|---|---|
| `ferrovue` | This crate: what generated code calls, and the framework integrations |
| [`ferrovue-core`]https://docs.rs/ferrovue-core | [`escape_into`], and JavaScript's numbers ([`push_int`], [`push_number`], [`Js`], …), which the other three share |
| [`ferrovue-router`]https://docs.rs/ferrovue-router | [`Router`] and what goes with it (the `router` feature) |
| [`ferrovue-i18n`]https://docs.rs/ferrovue-i18n | [`i18n`] and [`I18n`] the `i18n` feature |

# Rust version

ferrovue needs Rust 1.89 or later, the `rust-version` in its manifest, and CI tests that and the
latest stable. The generated code uses let-chains, so the crate that includes it must use
**edition 2024**.

# Versions

Use the same version of the npm package and of this crate: generated code is written against the
runtime of its own release. Byte-identical output also depends on Vue's own SSR compiler, so the
npm package compiles with an exact `@vue/compiler-sfc`, and its fixtures are recorded from one
version of Vue and of each integration. Your client may use any later patch of the same minor
(`vue` `~3.5.44`); the repository's README lists the range of every peer.

# Links

- The repository, with the compiler's full documentation and a complete axum + Vite example:
  <https://github.com/Kinsmir/ferrovue>
- The compiler on npm: <https://www.npmjs.com/package/ferrovue>
- [The guide]crate::guide, in this documentation.