2 releases
Uses new Rust 2024
| 0.1.1 | Feb 20, 2026 |
|---|---|
| 0.1.0 | Feb 20, 2026 |
#171 in Template engine
115KB
2K
SLoC
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::Writeimplementor - 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.tswith full access to environment variables
Quick Start
1. Write a template
<!-- templates/card.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
forme --source templates --output src/generated.rs
This produces a file with a rendering function for each template:
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
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:
[dependencies]
forme = "0.1"
Installation
# 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:
[dependencies]
forme = "0.1"
[build-dependencies]
forme = "0.1"
build.rs:
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:
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:
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:
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
# 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
| Flag | Short | Description |
|---|---|---|
--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.
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:
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.
<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:
<tpl-arg name="content" type="&str" default='""'/>
When referencing custom types, use module paths relative to the generated file:
<tpl-arg name="items" type="&[super::types::Item]"/>
Conditional Rendering (tpl-if)
Show or hide an element based on a boolean Rust expression:
<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:
<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:
<div tpl-html="content_html">Fallback</div>
<!-- Renders: <div>{content_html}</div> -->
tpl-outer-html replaces the entire element (including the tag itself):
<div tpl-outer-html="content_html">Fallback</div>
<!-- Renders: {content_html} (no wrapping <div>) -->
Dynamic Attributes (tpl-attr:*)
Set attribute values dynamically using Rust expressions:
<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:
<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":
<ul>
<li tpl-repeat="item in items.iter()">
<span tpl-text="item.name">Item</span>
</li>
</ul>
With enumeration:
<li tpl-repeat="(idx, item) in items.iter().enumerate()">
<span tpl-text='format!("{}. {}", idx + 1, item.name)'>Item</span>
</li>
With filtering:
<div tpl-repeat="item in items.iter().filter(|i| i.in_stock)">
<p tpl-text="item.name">Product</p>
</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):
<div tpl-template="card.html"
tpl-arg:title='&"Card Title"'
tpl-arg:content='&"Body text"'>
</div>
<!-- Renders: <div>{card.html output}</div> -->
tpl-include -- inline mode (replaces the entire element):
<div tpl-include="card.html"
tpl-arg:title='&"Card Title"'>
</div>
<!-- Renders: {card.html output} (no wrapping <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:
<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>:
<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:
<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
| Directive | Syntax | Purpose |
|---|---|---|
<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.
Generated Code
Understanding what forme produces helps you reason about performance and debug issues.
Template:
<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:
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-argbecomes a function parameter with the declared Rust type tpl-ifbecomes a Rustifblocktpl-repeatbecomes aforlooptpl-textcallsforme::html_escape()(or your custom escape function)tpl-html/tpl-outer-htmlwrite content directly without escaping- The generated file starts with
#![cfg_attr(rustfmt, rustfmt::skip)]socargo fmtskips it - Template file names map to function names:
card.htmlbecomesrender_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:
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:
// 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;
},
};
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/ directory contains a full showcase exercising most template features. See examples/README.md for detailed documentation.
# 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, nottypes::Item) - All variables in template expressions must come from
<tpl-arg>declarations
Attributes not appearing in output
- Use
tpl-attr:name="expr"syntax (nottpl-attr-name) - For boolean attributes, use
tpl-optional-attr:name="bool_expr" - Inspect the generated
.rsfile 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 Rustformat!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
Dependencies
~7–9.5MB
~172K SLoC