ferrovue 0.2.0

Vue templates compiled to Rust render functions: server-side rendering with no JavaScript at run time
Documentation
From nothing to a rendered component.

# 1. Install both halves

The compiler is an npm package, run at build time; this crate is what its output calls at run time.
Use the same version of both.

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

`serde` is needed because every generated props struct derives `serde::Serialize`: an island
carries its props to the client as JSON. `serde_json` is a dev-dependency because the generated
`mod.rs` holds a `#[cfg(test)]` function that renders a component from props written as JSON, for a
project's own conformance tests.

The crate that includes the generated code must use **edition 2024**, because that code uses
let-chains, and the latest stable Rust.

# 2. Configure

`ferrovue.config.json`, at the project root. Paths are relative to it.

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

| Key | Required | Meaning |
|---|---|---|
| `components` | yes | Directory of `.vue` files to compile |
| `out` | yes | Directory the Rust modules are written to. **Everything in it is replaced.** |
| `routes` | no | JSON file listing the app's routes: each a vue-router path, or `{ "path", "name", "children" }`. Needed for `<RouterLink>`, `<RouterView>` and `useRoute()`. See [`routing`]crate::guide::routing |
| `router` | no | Instead of `routes`: `{ routes, base?, linkActiveClass?, linkExactActiveClass? }`, matching `createWebHistory(base)` and `createRouter`'s options |
| `stores` | no | Directory of Pinia stores whose state components may read. See [`pinia`]crate::guide::pinia |
| `trustedHtml` | no | Rust path of the type a `TrustedHtml` prop is, e.g. `crate::html::Sanitised`. Needed for `v-html`. See [`escaping`]crate::guide::escaping |
| `helpers` | no | `{ module, functions }`: functions a template may call, each mapped to a Rust twin. See [`errors_and_limits`]crate::guide::errors_and_limits#helpers |
| `i18n` | no | vue-i18n: `{ messages, locale?, fallbackLocale? }`: the directory of locale files (`en.json`, `nl.json`), the default locale and the fallbacks. See [`i18n`]crate::guide::i18n |
| `clientDirectives` | no | Custom directives with no server output, by name without `v-`: `["focus"]` |
| `scopeId` | no | How a `<style scoped>` id is hashed, as `@vitejs/plugin-vue` hashes it: `"filepath-source"` (the default, the plugin's in a production build) or `"filepath"`. See [`scoped_styles`]crate::guide::scoped_styles |
| `viteRoot` | no | Vite's root, relative to this file's directory, from which a component's path is hashed for its scope id (default `.`) |

# 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
npx ferrovue            # write the modules
npx ferrovue --check    # write nothing; exit 1 if the committed modules are stale (for CI)
npx ferrovue --watch    # write them, then again on every change
```

A Vite project can use the plugin in `ferrovue/vite` instead of `--watch`: it regenerates on change
and shows a refused construct in Vite's error overlay.

The compiler writes `src/generated/greeting.rs` and `src/generated/mod.rs`, which declares one
module per component. This is `greeting.rs`, exactly as written (only the reservation is an
estimate the compiler works out per component):

```rust
# 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)
}
# }
```

Note what the template became: `v-if` is an `if` with Vue's `<!---->` placeholder in the `else`,
the integer is written with [`push_int`](crate::push_int) as JavaScript writes numbers, and every
interpolation goes through [`escape_into`](crate::escape_into).

# 5. Render

Declare the generated module once. `#[rustfmt::skip]` keeps rustfmt from rewriting the files, which
`ferrovue --check` would then report as stale:

```rust,ignore
// src/main.rs or src/lib.rs
#[rustfmt::skip]
mod generated;

use generated::greeting::{self, Props};
```

Then build the props and 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};
// Borrowed strings cost nothing; owned ones (a `String` from a database) work as well.
let user = String::from("Ada");
let props = Props::new(user.as_str(), 3);

// Into a buffer the page is already being written to.
let mut page = String::from("<!doctype html><html><body>");
greeting::render(&mut page, &props);
page.push_str("</body></html>");
assert_eq!(
    page,
    r#"<!doctype html><html><body><p class="greeting">Hello, Ada!<b>3 new</b><!----></p></body></html>"#
);

// As an island the client hydrates from its own props.
let island = greeting::island(&props).into_string();
assert_eq!(
    island,
    concat!(
        r#"<div data-island="Greeting" data-props="{&quot;name&quot;:&quot;Ada&quot;,&quot;unread&quot;:3}">"#,
        r#"<p class="greeting">Hello, Ada!<b>3 new</b><!----></p></div>"#,
    )
);
```

From here:

- [`generated_code`]crate::guide::generated_code describes every module the compiler writes;
- [`islands_and_hydration`]crate::guide::islands_and_hydration is the client side;
- the repository's
  [`examples/fullstack`]https://github.com/Kinsmir/ferrovue/tree/main/examples/fullstack is a
  complete axum server and Vite client to copy.