# forme
A compile-time HTML template engine for Rust. Write templates as plain `.html` files with `tpl-*` directives, and `forme` generates type-safe Rust rendering functions that write HTML via `std::fmt::Write`.
## Why forme?
Most template engines either invent a custom syntax (Jinja2-style `{% %}` blocks) or embed HTML inside Rust macros. Both break standard HTML tooling. `forme` takes a different approach: templates are **plain HTML** with data-binding expressed as attributes. Your HTML editor, linter, and formatter keep working. Designers can open templates in a browser and see the fallback content.
## Features
- **Plain HTML templates** -- no custom syntax to learn; templates are valid HTML with `tpl-*` attributes
- **Compile-time code generation** -- templates are compiled to Rust functions checked by `rustc`
- **Type-safe arguments** -- template parameters are Rust types (`&str`, `Vec<String>`, custom structs)
- **Zero-allocation rendering** -- generated code writes directly to any `std::fmt::Write` implementor
- **Template composition** -- include and nest templates with argument passing and slot-based content
- **Optional JS/TS transform pipeline** -- preprocess custom elements into standard HTML (e.g. Bootstrap/Stimulus components)
- **Config file support** -- define build targets in a `formefile.ts` with full access to environment variables
## Quick Start
### 1. Write a template
```html
<tpl-arg name="title" type="&str"/>
<tpl-arg name="content" type="&str" default='""'/>
<div class="card">
<h3 tpl-text="title">Card Title</h3>
<p tpl-if="!content.is_empty()" tpl-text="content">Placeholder</p>
</div>
```
### 2. Generate Rust code
```bash
forme --source templates --output src/generated.rs
```
This produces a file with a rendering function for each template:
```rust,ignore
pub fn render_card(
out: &mut impl std::fmt::Write,
title: &str,
content: &str,
) -> std::fmt::Result {
write!(out, "<div class=\"card\">")?;
write!(out, "<h3>")?;
forme::html_escape(out, &(title))?;
write!(out, "</h3>")?;
if !content.is_empty() {
write!(out, "<p>")?;
forme::html_escape(out, &(content))?;
write!(out, "</p>")?;
}
write!(out, "</div>")?;
Ok(())
}
```
### 3. Use in your Rust code
```rust,ignore
mod generated;
use generated::render_card;
fn main() {
let mut html = String::new();
render_card(&mut html, "Hello", "World").unwrap();
println!("{}", html);
}
```
Add `forme` as a dependency for the `html_escape` function:
```toml
[dependencies]
forme = "0.1"
```
## Installation
```bash
# Install the CLI binary from crates.io
cargo install forme
# Or from source
git clone https://github.com/DmitryBochkarev/forme
cd forme
cargo install --path .
```
After installation, the `forme` binary is available in your `$PATH`.
## Build Integration
You can compile templates automatically during `cargo build` using a build script. There are two approaches:
### Library API (recommended)
Add `forme` as a **build dependency** and call the library API directly -- no need to install the `forme` binary:
**`Cargo.toml`:**
```toml
[dependencies]
forme = "0.1"
[build-dependencies]
forme = "0.1"
```
**`build.rs`:**
```rust,ignore
fn main() {
forme::Builder::new("templates", "src/generated.rs")
.rerun_if_changed(true)
.build()
.expect("forme template compilation failed");
}
```
This approach is simpler -- no external binary required, and `cargo:rerun-if-changed` directives are emitted automatically for every template file.
### CLI in build.rs
Alternatively, install the `forme` binary and invoke it from `build.rs`:
**Project structure:**
```text
my-app/
├── build.rs
├── Cargo.toml
├── formefile.ts # or pass --source/--output in build.rs
├── templates/
│ ├── card.html
│ └── page.html
└── src/
├── main.rs
├── types.rs
└── generated.rs # auto-generated by forme
```
**`build.rs`:**
```rust,ignore
use std::process::Command;
fn main() {
// Re-run if any template changes
println!("cargo:rerun-if-changed=templates");
println!("cargo:rerun-if-changed=formefile.ts");
let status = Command::new("forme")
// Option A: use a config file
.args(["--config", "formefile.ts"])
// Option B: pass flags directly
// .args(["--source", "templates", "--output", "src/generated.rs"])
.status()
.expect("failed to run forme -- is it installed? (cargo install --path <forme-repo>)");
if !status.success() {
panic!("forme template compilation failed");
}
}
```
**`src/main.rs`:**
```rust,ignore
mod types;
mod generated;
use generated::render_card;
fn main() {
let mut html = String::new();
render_card(&mut html, "Title", "Content").unwrap();
println!("{}", html);
}
```
With this setup, `cargo build` regenerates templates automatically whenever the source templates or config change.
## Usage
```bash
# Basic usage
forme --source <template-dir> --output <output-file.rs>
# With a config file
forme --config formefile.ts
# Auto-discover formefile.ts in current directory
forme
# With JS/TS transform script
forme --source templates --output src/generated.rs --transform-script components.ts
# With custom escape function
forme --source templates --output src/generated.rs --escape-func "my_crate::escape"
# Initialize a new config file
forme init
```
### CLI Options
| `--source <DIR>` | `-s` | Source template directory |
| `--output <FILE>` | `-o` | Output Rust file path |
| `--transform-script <FILE>` | `-t` | Optional JS/TS transform script |
| `--escape-func <FUNC>` | `-e` | Custom escape function (default: `forme::html_escape`) |
| `--config <FILE>` | `-c` | Path to config file |
## Configuration
Instead of passing CLI flags, you can define build configuration in a `formefile.ts` (or `formefile.js`). Run `forme init` to scaffold one.
```typescript
export function config(ctx: FormeContext): FormeConfig {
return {
source: "templates",
output: "src/generated.rs",
};
}
interface FormeContext {
cwd: string;
env: Record<string, string>;
cli: {
source?: string;
output?: string;
transform_script?: string;
escape_func?: string;
};
}
interface FormeConfig {
source: string;
output: string;
transform_script?: string;
escape_func?: string;
}
```
The config function receives the current working directory, all environment variables, and any CLI overrides. It can return a single config or an array for multi-target builds.
**Using environment variables:**
```typescript
export function config(ctx: FormeContext): FormeConfig {
const env = ctx.env.APP_ENV || "development";
return {
source: `templates/${env}`,
output: "src/generated.rs",
escape_func: env === "production" ? "my_crate::strict_escape" : undefined,
};
}
```
## Template Directives
All directives use the `tpl-` prefix and contain Rust expressions.
### Template Arguments (`<tpl-arg>`)
Declare the parameters your template expects. These become function arguments in the generated Rust code.
```html
<tpl-arg name="title" type="&str"/>
<tpl-arg name="items" type="&[Item]"/>
<tpl-arg name="user" type="Option<&User>"/>
```
Use the `default` attribute for optional arguments:
```html
<tpl-arg name="content" type="&str" default='""'/>
```
When referencing custom types, use module paths relative to the generated file:
```html
<tpl-arg name="items" type="&[super::types::Item]"/>
```
### Conditional Rendering (`tpl-if`)
Show or hide an element based on a boolean Rust expression:
```html
<header tpl-if="show_header">
<h1>Welcome</h1>
</header>
<div tpl-if="user.is_some()">
User is logged in
</div>
<p tpl-if="items.is_empty()">No items found.</p>
```
The entire element and its children are omitted when the expression is `false`.
### Text Content (`tpl-text`)
Replace an element's children with HTML-escaped text from a Rust expression:
```html
<h1 tpl-text="title">Fallback Title</h1>
<span tpl-text='format!("Hello, {}!", name)'>Hello!</span>
```
The original text inside the element serves as fallback content visible when previewing the template in a browser. Use single quotes for the attribute when the expression contains double quotes.
### Raw HTML (`tpl-html`, `tpl-outer-html`)
Insert unescaped HTML content. Use with caution -- the content is not escaped.
`tpl-html` replaces the element's **children**:
```html
<div tpl-html="content_html">Fallback</div>
```
`tpl-outer-html` replaces the **entire element** (including the tag itself):
```html
<div tpl-outer-html="content_html">Fallback</div>
```
### Dynamic Attributes (`tpl-attr:*`)
Set attribute values dynamically using Rust expressions:
```html
<div tpl-attr:class='format!("status-{}", status)'
tpl-attr:id="element_id">
Content
</div>
<a tpl-attr:href='format!("/user/{}", user.id)'>Profile</a>
```
### Optional Attributes (`tpl-optional-attr:*`)
Conditionally include a boolean attribute. The attribute is rendered only when the expression is `true`:
```html
<button tpl-optional-attr:disabled="!is_enabled">Click me</button>
<input type="checkbox" tpl-optional-attr:checked="user.is_admin">
```
### Repeating Elements (`tpl-repeat`)
Loop over a collection. The syntax is `tpl-repeat="variable in iterator"`:
```html
<ul>
<li tpl-repeat="item in items.iter()">
<span tpl-text="item.name">Item</span>
</li>
</ul>
```
With enumeration:
```html
<li tpl-repeat="(idx, item) in items.iter().enumerate()">
<span tpl-text='format!("{}. {}", idx + 1, item.name)'>Item</span>
</li>
```
With filtering:
```html
</div>
```
### Template Composition (`tpl-template`, `tpl-include`)
Include other templates as reusable components. Pass arguments with `tpl-arg:name` attributes.
**`tpl-template`** -- wrapping mode (replaces the element's children with the rendered template):
```html
<div tpl-template="card.html"
tpl-arg:title='&"Card Title"'
tpl-arg:content='&"Body text"'>
</div>
```
**`tpl-include`** -- inline mode (replaces the entire element):
```html
<div tpl-include="card.html"
tpl-arg:title='&"Card Title"'>
</div>
```
Include the `.html` extension in template names. Use `&` to pass string literals or owned values as references.
### Slot Content (`tpl-slot:*`, `tpl-slot-items:*`)
Slots let you pass rendered HTML blocks to a template, rather than simple expressions.
**`tpl-slot:name`** renders children into a single `&str`:
```html
<div tpl-template="card.html" tpl-arg:title="&u.name">
<template tpl-slot:content_html>
<p><strong>Name:</strong> <span tpl-text="u.name">?</span></p>
<p><strong>Email:</strong> <span tpl-text="u.email">?</span></p>
</template>
</div>
```
**`tpl-slot-items:name`** renders each direct child as a separate `String`, collected into a `Vec<String>`:
```html
<template tpl-include="header.html">
<template tpl-slot-items:top_items>
<a href="/home">Home</a>
<a href="/about">About</a>
</template>
<template tpl-slot-items:menu_items>
<a href="/settings">Settings</a>
</template>
</template>
```
Children inside slots can use any directive (`tpl-repeat`, `tpl-if`, `tpl-text`, etc.).
### Combining Directives
Multiple directives can be used on the same element:
```html
<button tpl-if="is_visible"
tpl-optional-attr:disabled="!is_enabled"
tpl-attr:class='format!("btn-{}", style)'
tpl-text="label">
Click
</button>
```
Directives are processed in a fixed order: `tpl-repeat` > `tpl-if` > `tpl-text` / `tpl-html` / `tpl-outer-html` > `tpl-attr:*` > `tpl-optional-attr:*` > `tpl-template` / `tpl-include`.
### Directive Reference
| `<tpl-arg>` | `<tpl-arg name="x" type="T"/>` | Declare a template argument |
| `<tpl-arg>` (default) | `<tpl-arg name="x" type="T" default='val'/>` | Argument with default value |
| `tpl-if` | `tpl-if="bool_expr"` | Conditional rendering |
| `tpl-repeat` | `tpl-repeat="var in iterator"` | Loop over a collection |
| `tpl-text` | `tpl-text="expr"` | Text content (HTML-escaped) |
| `tpl-html` | `tpl-html="expr"` | Raw HTML (replaces children) |
| `tpl-outer-html` | `tpl-outer-html="expr"` | Raw HTML (replaces entire element) |
| `tpl-attr:name` | `tpl-attr:class="expr"` | Dynamic attribute value |
| `tpl-optional-attr:name` | `tpl-optional-attr:disabled="bool"` | Conditional boolean attribute |
| `tpl-template` | `tpl-template="file.html"` | Include template (wrapping) |
| `tpl-include` | `tpl-include="file.html"` | Include template (inline) |
| `tpl-arg:name` | `tpl-arg:title="expr"` | Pass argument to included template |
| `tpl-slot:name` | `tpl-slot:content` | Pass slot as `&str` |
| `tpl-slot-items:name` | `tpl-slot-items:children` | Pass slot items as `Vec<String>` |
For more examples of each directive, see [`examples/README.md`](examples/README.md).
## Generated Code
Understanding what `forme` produces helps you reason about performance and debug issues.
**Template:**
```html
<tpl-arg name="items" type="&[Item]"/>
<ul tpl-if="!items.is_empty()">
<li tpl-repeat="item in items.iter()" tpl-text="item.name">placeholder</li>
</ul>
```
**Generated Rust:**
```rust,ignore
pub fn render_list(
out: &mut impl std::fmt::Write,
items: &[Item],
) -> std::fmt::Result {
if !items.is_empty() {
write!(out, "<ul>")?;
for item in items.iter() {
write!(out, "<li>")?;
forme::html_escape(out, &(item.name))?;
write!(out, "</li>")?;
}
write!(out, "</ul>")?;
}
Ok(())
}
```
Key observations:
- Each `tpl-arg` becomes a function parameter with the declared Rust type
- `tpl-if` becomes a Rust `if` block
- `tpl-repeat` becomes a `for` loop
- `tpl-text` calls `forme::html_escape()` (or your custom escape function)
- `tpl-html` / `tpl-outer-html` write content directly without escaping
- The generated file starts with `#![cfg_attr(rustfmt, rustfmt::skip)]` so `cargo fmt` skips it
- Template file names map to function names: `card.html` becomes `render_card`
## JS/TS Transform Pipeline
The optional `--transform-script` flag enables a preprocessing step that transforms HTML elements before template compilation. This is useful for mapping component shorthand to framework-specific HTML with the right CSS classes and data attributes.
**How it works:** For each HTML element, `forme` calls the `processor.elementHeader()` function exported by your script. The function receives the element's tag name, attributes, and metadata, and returns a (potentially modified) element.
**Interface:**
```typescript
export interface Processor {
elementHeader: (element: JsElement) => JsElement;
}
interface JsElement {
name: string; // tag name
attributes: JsAttr[]; // HTML attributes
is_void: boolean; // void element flag
}
```
**Minimal example:**
```typescript
// my-transforms.ts
export const processor: Processor = {
elementHeader: function(element: JsElement): JsElement {
// Convert <card> to <div class="card">
if (element.name === "card") {
element.name = "div";
element.attributes.push({
name: "class",
conditions: { ty: "Empty", expr: "", list: [] },
values: [{ ty: "Text", conditions: { ty: "Empty", expr: "", list: [] }, content: "card" }],
});
}
return element;
},
};
```
```bash
forme --source templates --output src/generated.rs --transform-script my-transforms.ts
```
The included `examples/components.ts` provides a full reference implementation with transforms for Bootstrap and Stimulus.js components (buttons, forms, cards, modals, etc.).
## Examples
The [`examples/`](examples/) directory contains a full showcase exercising most template features. See [`examples/README.md`](examples/README.md) for detailed documentation.
```bash
# Generate the example templates
cargo run -- --config examples/formefile.ts
# Run the showcase
cargo run --example showcase_example
```
The showcase demonstrates `tpl-if`, `tpl-repeat`, `tpl-text`, `tpl-attr`, `tpl-include`, `tpl-template` with slots, and nested template composition.
## Troubleshooting
### "Found 0 template file(s)"
Ensure template files have `.html` or `.htm` extensions and the `--source` path is correct.
### Type errors in generated code
- Verify `<tpl-arg>` types match your Rust types exactly
- Check module paths are relative to the generated file (e.g. `super::types::Item`, not `types::Item`)
- All variables in template expressions must come from `<tpl-arg>` declarations
### Attributes not appearing in output
- Use `tpl-attr:name="expr"` syntax (not `tpl-attr-name`)
- For boolean attributes, use `tpl-optional-attr:name="bool_expr"`
- Inspect the generated `.rs` file to see what code was produced
### Quote / escaping issues
- Use **single quotes** for the HTML attribute when the Rust expression contains double quotes: `tpl-text='format!("hi {}", name)'`
- Avoid inline `<style>` tags with CSS -- curly braces `{}` conflict with Rust `format!` strings. Use external stylesheets instead.
### Generated code not updating
If using `build.rs`, ensure `cargo:rerun-if-changed` includes both the template directory and the config file.
## License
MIT