Skip to main content

Crate forme

Crate forme 

Source
Expand description

§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

<!-- 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:

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

FlagShortDescription
--source <DIR>-sSource template directory
--output <FILE>-oOutput Rust file path
--transform-script <FILE>-tOptional JS/TS transform script
--escape-func <FUNC>-eCustom escape function (default: forme::html_escape)
--config <FILE>-cPath 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

DirectiveSyntaxPurpose
<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-iftpl-if="bool_expr"Conditional rendering
tpl-repeattpl-repeat="var in iterator"Loop over a collection
tpl-texttpl-text="expr"Text content (HTML-escaped)
tpl-htmltpl-html="expr"Raw HTML (replaces children)
tpl-outer-htmltpl-outer-html="expr"Raw HTML (replaces entire element)
tpl-attr:nametpl-attr:class="expr"Dynamic attribute value
tpl-optional-attr:nametpl-optional-attr:disabled="bool"Conditional boolean attribute
tpl-templatetpl-template="file.html"Include template (wrapping)
tpl-includetpl-include="file.html"Include template (inline)
tpl-arg:nametpl-arg:title="expr"Pass argument to included template
tpl-slot:nametpl-slot:contentPass slot as &str
tpl-slot-items:nametpl-slot-items:childrenPass 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-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:

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, 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

Structs§

Builder
Builder for compiling HTML templates into Rust code.

Enums§

Error
Error type for forme template compilation.

Functions§

html_escape