articles

docs.rs switching jinja template framework from tera to rinja

docs.rs is an important part of the Rust ecosystem as it provides documentation for all crates published on crates.io.

Recently, we migrated the template engine we use to render web pages from tera to rinja in this pull request. This is a work that took us 10 months so I thought it could be interesting to talk about it. This blog post will give an overview of how docs.rs works and why we made this switch.

As a sidenote, the Rust documentation generator (rustdoc) also switched to rinja.

docs.rs

docs.rs is mainly composed of two parts:

  1. A server building crates documentation.
  2. A server serving documentation and all docs.rs web pages.

In this blog post, we will talk about the second point.

To serve pages, we need to handle HTTP requests. For that we use axum. But for the content of the pages, we use the jinja template language. Basically, it is an overlay of HTML. It allows to have logic in your HTML files which will be computed when they are generated. It looks like this:

{% include "head.html" %}

<body>
{% if let Some(name) = name %}
Hello {{ name }}!
{% else %}
Hello unknown person!
{% endif %}
</body>

To use jinja in docs.rs, we use the tera crate. This is the one I replaced with rinja.

So why did we feel the need for this replacement? There are a few things to take into account: even though tera parses provided templates only once (and store the generated AST), it still needs to regenerate the output from it. And this brings two issues:

  1. If a template is wrong, you will only discover it when you generate this invalid part of your template.
  2. It has a cost at runtime to regenerate the output from the AST every time.

But, because the output is regenerated from AST every time, it also means that you can have:

rinja

Unlike tera, rinja converts your jinja templates into Rust code at compile-time. It brings some advantages:

But also some inconveniences:

Many things like i18n support will arrive in the future as well.

So now, if you ever used a jinja template engine in Rust, you have might think:

What you're describing looks a lot like askama. Why are using rinja instead?

I originally planned to use askama for this migration. Problem: a lot of features were missing so I sent them 39 pull requests which were merged. However, at some point, some disagreements on syntax, features to be added and on code style appeared. No bad blood or anything, just that this divergence of opinion was slowing me down greatly into adding features I needed for this migration, so I forked askama and that's how rinja was born. @Kijewski (who was a maintainer of askama before joining me on rinja) and I are the owners and maintainers of rinja.

Before going further, I just wanted to thank @djc for their work on askama. This project is really awesome and without it, rinja would not have been possible that fast.

Now, let's go through the differences between askama and rinja.

Differences between askama and rinja

Binary operations

In askama, you can use binary operators such as | or ^ directly in the templates. The problem with this approach is that it makes it very confusing when you use filters:

{# Applying filter "y" to variable "b" #}
{% x|y %}
{# OR binary operation #}
{% x | y %}

It also made compilation errors trickier to handle. In rinja you can still do binary operations in the templates, but you will need to call it by its full name:

{# Applying filter "y" to variable "b" #}
{% x|y %}
{# OR binary operation #}
{% x bitor y %}

Done in #18.

Favor methods over closure fields

In askama, when calling:

{{ foo(12) }}

It will actually try to look for a field named foo which is a closure and call it this way:

Run(self.foo)(12);

Requiring your type to look like this:

Runstruct MyTemplate {
    foo: fn(i32) -> i32,
}

We switched this behavior to calling methods instead. You can still call field closures in your template by adding parens like you would in Rust code:

{{ (foo)(12) }}

Done in #37.

Support ".." in let pattern matching

You can now do this in your templates:

{% if let MyTemplate { a, .., b } = var %}
{% else if let MyTemplate { a, .. } = var %}
{% else if let MyTemplate { .. } = var %}

Done in #36.

"is (not) defined"

The big missing part from jinja is the is defined feature and we implemented it in this #78. It allows to check whether or not a variable is available in your template:

{% if x is defined %}
    {{ x }}
{% endif %}

{% if y is not defined %}
    y is not defined
{% else %}
    {{ y }}
{% endif %}

It will be part of the next release.

Support "@ .." in arrays pattern matching

The rest pattern is quite useful in Rust, so there is no reason to not implement it in rinja!

{% if let [1, 2, who @ .., 4] = [1, 2, 3, 4] %}
    {{"{:?}"|format(who)}}
{% endif %}

{% if let [who @ .., 4] = [1, 2, 3, 4] %}
    {{"{:?}"|format(who)}}
{% endif %}

{% if let [1, who @ ..] = [1, 2, 3, 4] %}
    {{"{:?}"|format(who)}}
{% endif %}

It is implemented in #104. It will be part of the next release.

Compilation errors improvement

It's VERY important to provide to the users as much information and context when something wrong happens during compilation so they can fix it quickly. Now, all errors show exactly where they happened alongside with a message thanks to these pull requests:

Allow to use "*" and "&" in expressions

Sometime (although, very rarely hopefully), it's nice to be able to dereference or send a reference to a variable:

{% let x = &"bla" %}
{% if *x == "bla" %}
...
{% endif %}

It's now possible thanks to #39.

Add support for "as" operator

In case you're comparing integers, or simply manipulating integers, it can be useful to be able to cast them directly in the template:

{% set x = 0u32 %}
{% set y = 0i8 %}
{% if x == y as u32 %}
    {{ y as i8 }}
{% endif %}

It was implemented in #63. It will be part of the next release.

Examples

It's a thing that was missing in askama that we added in rinja: examples! Well, only one for now but hopefully more in the future. :)

You can see the example(s) here.

Caching parsed templates and configuration file

Instead of parsing the same configuration and the template files multiple times (it's not rare to include the same parent template file to keep the same HTML structure), we now cache them. It allows to have much smaller compile-time thanks to this (comparison numbers are visible here).

Done in #59.

Performance improvements (for compilation-time)

We have worked a lot on reducing the time required for rinja to convert jinja templates to Rust code:

Book hosting

This one is not about the framework itself but more "around it". Currently, askama only hosts the last git version of its book, which is problematic when the new version provides a lot of new features which are not present in the last released version.

rinja provides the book for its development branch and for all its published version (only one for now) on readthedocs.

Words of the end

All this was done under the watchful(?) eyes of my cat:


my cat
Posted on the 31/07/2024 at 12:00 by @GuillaumeGomez

Next article

Doctests - How were they improved?

Previous article

Writing your own Rust linter
Back to articles list
RSS feedRSS feed