When I announced Snapfire 0.4.0, the pitch was about the development loop: stop restarting your server every time you touch an .html file. That part hasn't changed, and it still works the same way.
This release is about the engine underneath.
Tera 2 shipped and it isn't a version bump. It's a rewrite. The Rust API changed, the template language changed, and a handful of things that used to fail silently now fail loudly. Snapfire 0.5.0 moves to it.
Today I'm releasing Snapfire 0.5.0.
GitHub Repository: https://github.com/excsn/snapfire
Crates.io: https://crates.io/crates/snapfire
This is a breaking release. Both halves of your application are affected: your Rust code and your templates. There's a full MIGRATION.md in the repo, and I'd read it before upgrading. This post covers the parts I think are most worth knowing about.
Writing a Filter: Before and After
The clearest way to see the shape of Tera 2 is a custom filter.
Here's a simple uppercase filter under Tera 1. You take a &Value, you take a HashMap of arguments, and you do your own conversions in and out of serde_json::Value:
// Tera 1
fn upcase(
value: &tera::Value,
_: &HashMap<String, tera::Value>,
) -> tera::Result<tera::Value> {
let s = tera::from_value::<String>(value.clone())?;
Ok(tera::to_value(s.to_uppercase()).unwrap())
}
Here's the same filter under Tera 2:
// Tera 2
fn upcase(value: &str, _: Kwargs, _: &State) -> String {
value.to_uppercase()
}
That's the whole function.
The argument arrives already converted to the type you asked for. &str, String, i64, f64, bool, Vec<T>, Map and more all work directly. The return value can be anything that converts into a Value, so you only reach for TeraResult when something can actually fail. And there's a third parameter now, &State, which gives a filter access to the rendering context.
Named arguments come through Kwargs:
fn money(value: i64, kwargs: Kwargs, _: &State) -> TeraResult<String> {
let symbol: Option<&str> = kwargs.get("symbol")?;
Ok(format!("{}{}.{:02}", symbol.unwrap_or("$"), value / 100, value % 100))
}
{{ product.cents | money(symbol="£") }}
Functions and tests follow the same pattern. A function is Fn(Kwargs, &State) -> Res. A test is Fn(Arg, Kwargs, &State) -> bool.
The Change That Caught Me Out
Tera 2 resolves the names a template references while it parses the template. If your index.html says {{ name | upcase }} and nothing has registered an upcase filter yet, parsing fails.
That sounds harmless until you notice the order Snapfire 0.4 did things in: create the Tera instance from your glob, then run your configure_tera closure. Under Tera 2 that order can never work. Every template gets parsed before a single filter is registered.
So the order is inverted in 0.5.0. Your configure_tera closure now runs first, against an empty engine, and the glob is loaded afterwards.
let app_state = TeraWeb::builder("templates/**/*.html")
.configure_tera(|tera| {
tera.register_filter("upcase", upcase);
tera.register_filter("money", money);
})
.build()?;
You don't have to do anything differently. But it's worth understanding, because it changes when you find out about a mistake. Under Tera 1, a typo in a filter name was a 500 the first time someone hit that page. Now it's a failure at build(), before your server ever binds a port:
Tera error: error: Unknown filter `upcase`
--> index.html:1:11
|
1 | {{ name | upcase }}
| ^^^^^^
I like this trade a lot. It moves an entire class of bug from runtime to startup. There's a new build_diagnostics example in the repo that does nothing but show you what this looks like for unregistered filters, unknown components and malformed templates.
Undefined Variables Are Errors Now
Under Tera 1, {{ typo }} quietly rendered nothing. Under Tera 2 it's an error.
This is the change most likely to surprise you when you upgrade, because a template that "worked" may have been quietly hiding a misspelled variable for months. Accessing a missing field on an existing object errors too:
{{ user.naem }} {# was: empty string. now: an error #}
You get one level of undefined-ness, so {% if not_existing %} is still fine. Reaching through something undefined is not.
Macros Are Gone. Components Replaced Them.
Tera 2 removed macros entirely. In their place are components, which cover the same ground with a nicer syntax.
You define one in any template inside your glob:
{% component price_tag(name, cents) %}
<li><strong>{{ name | upcase }}</strong> {{ cents | money }}</li>
{% endcomponent price_tag %}
And call it from any other:
<ul>
{% for product in products %}
{{ <price_tag name={product.name} cents={product.cents}/> }}
{% endfor %}
</ul>
Quoted values are literals, braces are expressions. Parameters can have defaults, and a component can take a body that shows up inside as {{ body }}.
There's also a pile of smaller template changes: my_vec.0 has to be written my_vec[0], several filters were renamed (escape → escape_html, as_str → str, divisibleby → divisible_by), and a number were removed outright (date, json_encode, slugify, urlencode, map, filter, concat and others). MIGRATION.md has the complete tables, built by diffing what Tera 1 actually registered against what Tera 2 does.
While I Was In There
Upgrading meant reading a lot of Snapfire's own code, and writing an example that used every feature turned up two bugs that had nothing to do with Tera.
ws_path didn't work. You could move the live-reload WebSocket to a custom path, and the route would move correctly, but the script injected into your pages still had the default path hardcoded in it. The browser connected to a URL that no longer existed and live reload just silently stopped working. The path is now substituted into the script at injection time.
auto_inject_script(false) did nothing. The value was stored and never read. Injection happened regardless.
That second one is fixed, and it comes with the piece that was missing to make it useful. If you turn injection off, you presumably still want the reload client, just placed on your own terms. So there's a new method:
let script = app_state.reload_script();
It hands back the client as JavaScript source, with your configured path already substituted, and deliberately without a <script> tag around it, so you own the element:
<script nonce="{{ csp_nonce }}">{{ reload_script | safe }}</script>
That's the case that motivated it. Snapfire's injected script is inline and carries no nonce, so a strict Content-Security-Policy blocks it. Now you can attach your own. It's also the answer if you're returning HTML fragments, where the middleware would otherwise append a full reload client to every partial.
Without the devel feature it returns an empty string, so the same template works in release without a conditional.
So What Do You Get?
A faster, stricter engine. Tera 2 is a rewrite, and Snapfire turns on its fast feature for you.
Template errors at startup, not at 3am. Unknown filters, functions, tests and components fail build() with the offending source line and a caret under it.
Components instead of macros, with defaults, bodies and a cleaner call syntax.
A CSP-friendly escape hatch. auto_inject_script(false) plus reload_script() lets you place the live-reload client yourself, under your own nonce.ws_path that actually works.
Real documentation. There's now a proper usage guide alongside the README and API reference, and four runnable examples in the repo.
Upgrading
The short version:
[dependencies]
snapfire = "0.5"
tera = "2"
Then expect to touch two things: any custom filters, functions or tests you've written, and any template using macros, .0 array indexing, a removed filter, or a variable that was quietly undefined.
MIGRATION.md has the full mapping as tables you can work through, including the complete list of renamed and removed builtins.
If it helps, the build_diagnostics example is a fast way to see what the new errors look like before you go hunting through your own templates:
cargo run -p snapfire --example build_diagnostics
The Vision
This hasn't changed. Snapfire is built around a framework-agnostic core with a thin Actix layer on top, because Actix and Tera are what I reach for. I'd still like to see Axum or Rocket layers, and other template engines behind the same live-reload machinery. Moving the reload client into the core during this release actually nudged things a little further in that direction, since a second framework integration would now find it already waiting there.
Give It a Try
- Check out the code on GitHub: https://github.com/excsn/snapfire
- Add it to your project from Crates.io: https://crates.io/crates/snapfire
If you hit something during the upgrade that MIGRATION.md doesn't cover, please open an issue. That document was written by working through the changes on a real codebase, so I'd rather grow it from what people actually run into than guess.
