#compile-time #html #web

bin+lib forme

Compile-time HTML template engine — plain HTML templates with tpl-* directives generate type-safe Rust rendering functions

2 releases

Uses new Rust 2024

0.1.1 Feb 20, 2026
0.1.0 Feb 20, 2026

#171 in Template engine

MIT license

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

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

Dependencies

~7–9.5MB
~172K SLoC