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 is mainly composed of two parts:
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:
But, because the output is regenerated from AST every time, it also means that you can have:
All features from jinja (since variables are stored into a map, you can check if they're defined, etc).
So based on this, why is docs.rs switching away from it? The answer is simple: to limit the bugs uncovered at runtime, we prefer to have them directly at compile-time. That's where rinja comes in.
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.
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.
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.
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.
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.
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.
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:
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.
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.
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.
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.
We have worked a lot on reducing the time required for rinja to convert jinja templates to Rust code:
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.
All this was done under the watchful(?) eyes of my cat: