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 -->
Card Title
Placeholder
2. Generate Rust code
This produces a file with a rendering function for each template:
3. Use in your Rust code
use render_card;
Add forme as a dependency for the html_escape function:
[]
= "0.1"
Installation
# Install the CLI binary from crates.io
# Or from source
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:
[]
= "0.1"
[]
= "0.1"
build.rs:
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 Command;
src/main.rs:
use render_card;
With this setup, cargo build regenerates templates automatically whenever the source templates or config change.
Usage
# Basic usage
# With a config file
# Auto-discover formefile.ts in current directory
# With JS/TS transform script
# With custom escape function
# Initialize a new config file
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.
Use the default attribute for optional arguments:
When referencing custom types, use module paths relative to the generated file:
Conditional Rendering (tpl-if)
Show or hide an element based on a boolean Rust expression:
Welcome
User is logged in
No items found.
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:
Fallback Title
Hello!
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:
Fallback
<!-- Renders: <div>{content_html}</div> -->
tpl-outer-html replaces the entire element (including the tag itself):
Fallback
<!-- Renders: {content_html} (no wrapping <div>) -->
Dynamic Attributes (tpl-attr:*)
Set attribute values dynamically using Rust expressions:
Content
Profile
Optional Attributes (tpl-optional-attr:*)
Conditionally include a boolean attribute. The attribute is rendered only when the expression is true:
Click me
Repeating Elements (tpl-repeat)
Loop over a collection. The syntax is tpl-repeat="variable in iterator":
Item
With enumeration:
Item
With filtering:
Product
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):
<!-- Renders: <div>{card.html output}</div> -->
tpl-include -- inline mode (replaces the entire element):
<!-- 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:
Name: ?
Email: ?
tpl-slot-items:name renders each direct child as a separate String, collected into a Vec<String>:
Home
About
Settings
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:
Click
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:
placeholder
Generated Rust:
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;
},
};
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
# Run the showcase
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